feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,10 @@ import { useState, useCallback } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||||
import { getNoteById } from '@/app/actions/notes'
|
||||
import { getNoteById, deleteNote, toggleArchive } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
||||
@@ -16,7 +19,9 @@ interface ArchiveClientProps {
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
export function ArchiveClient({ notes }: ArchiveClientProps) {
|
||||
export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly: boolean } | null>(null)
|
||||
|
||||
const handleOpen = useCallback(async (note: Note, readOnly = false) => {
|
||||
@@ -28,13 +33,45 @@ export function ArchiveClient({ notes }: ArchiveClientProps) {
|
||||
setEditingNote(null)
|
||||
}, [])
|
||||
|
||||
const handleArchiveNote = useCallback(async (note: Note) => {
|
||||
const nextArchived = !note.isArchived
|
||||
if (nextArchived) {
|
||||
setNotes((prev) => prev.filter((n) => n.id !== note.id))
|
||||
}
|
||||
try {
|
||||
await toggleArchive(note.id, nextArchived, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, isArchived: nextArchived } })
|
||||
toast.success(
|
||||
nextArchived ? (t('notes.archived') || 'Archivée') : (t('notes.unarchived') || 'Désarchivée')
|
||||
)
|
||||
} catch {
|
||||
if (nextArchived) setNotes((prev) => [note, ...prev])
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleDeleteNote = useCallback(async (note: Note) => {
|
||||
setNotes((prev) => prev.filter((n) => n.id !== note.id))
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}, [t])
|
||||
|
||||
if (editingNote) {
|
||||
return (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={handleClose}
|
||||
onNoteSaved={() => {}}
|
||||
onNoteSaved={(saved) => {
|
||||
setNotes((prev) => prev.map((n) => (n.id === saved.id ? { ...n, ...saved } : n)))
|
||||
emitNoteChange({ type: 'updated', note: saved })
|
||||
}}
|
||||
fullPage
|
||||
/>
|
||||
)
|
||||
@@ -44,6 +81,8 @@ export function ArchiveClient({ notes }: ArchiveClientProps) {
|
||||
<NotesEditorialView
|
||||
notes={notes}
|
||||
onOpen={handleOpen}
|
||||
onArchiveNote={handleArchiveNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Sparkles, CheckCircle2, Loader2, Tag } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -29,6 +30,7 @@ export function AutoLabelSuggestionDialog({
|
||||
onLabelsCreated,
|
||||
}: AutoLabelSuggestionDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [suggestions, setSuggestions] = useState<AutoLabelSuggestion | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -46,6 +48,9 @@ export function AutoLabelSuggestionDialog({
|
||||
const fetchSuggestions = async () => {
|
||||
if (!notebookId) return
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/auto-labels', {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Wand2, Loader2, ChevronRight, CheckCircle2 } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import type { OrganizationPlan, NotebookOrganization } from '@/lib/ai/services'
|
||||
|
||||
interface BatchOrganizationDialogProps {
|
||||
@@ -29,6 +30,7 @@ export function BatchOrganizationDialog({
|
||||
onNotesMoved,
|
||||
}: BatchOrganizationDialogProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [plan, setPlan] = useState<OrganizationPlan | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [applying, setApplying] = useState(false)
|
||||
@@ -40,6 +42,12 @@ export function BatchOrganizationDialog({
|
||||
setLoading(true)
|
||||
setFetchError(null)
|
||||
try {
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch('/api/ai/batch-organize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
196
memento-note/components/block-picker.tsx
Normal file
196
memento-note/components/block-picker.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Search, Sparkles, Link2, X, Folder } from 'lucide-react'
|
||||
|
||||
export interface BlockSuggestion {
|
||||
blockId: string
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
notebookName: string
|
||||
content: string
|
||||
snippet: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
interface BlockPickerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
currentNoteId?: string
|
||||
onSelectBlock: (block: BlockSuggestion) => void
|
||||
}
|
||||
|
||||
export function BlockPicker({ isOpen, onClose, currentNoteId, onSelectBlock }: BlockPickerProps) {
|
||||
const [activeTab, setActiveTab] = useState<'suggestions' | 'search'>('suggestions')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<BlockSuggestion[]>([])
|
||||
const [searchResults, setSearchResults] = useState<BlockSuggestion[]>([])
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
const [loadingSearch, setLoadingSearch] = useState(false)
|
||||
|
||||
// Load AI suggestions when opening
|
||||
useEffect(() => {
|
||||
if (!isOpen || !currentNoteId) return
|
||||
setLoadingSuggestions(true)
|
||||
fetch(`/api/blocks/suggestions?noteId=${currentNoteId}`)
|
||||
.then(r => r.json())
|
||||
.then((data: { blocks: BlockSuggestion[] }) => setSuggestions(data.blocks || []))
|
||||
.catch(() => setSuggestions([]))
|
||||
.finally(() => setLoadingSuggestions(false))
|
||||
}, [isOpen, currentNoteId])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'search') return
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setLoadingSearch(true)
|
||||
const params = new URLSearchParams({ q: searchQuery })
|
||||
if (currentNoteId) params.set('excludeNoteId', currentNoteId)
|
||||
fetch(`/api/blocks/search?${params}`)
|
||||
.then(r => r.json())
|
||||
.then((data: { blocks: BlockSuggestion[] }) => setSearchResults(data.blocks || []))
|
||||
.catch(() => setSearchResults([]))
|
||||
.finally(() => setLoadingSearch(false))
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchQuery, activeTab, currentNoteId])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const displayBlocks = activeTab === 'suggestions' ? suggestions : searchResults
|
||||
const isLoading = activeTab === 'suggestions' ? loadingSuggestions : loadingSearch
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/30 dark:bg-black/50 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||
transition={{ duration: 0.15, ease: 'easeOut' }}
|
||||
className="w-[480px] max-w-full bg-[#F5F4F2] dark:bg-zinc-900 backdrop-blur-md rounded-2xl border border-[#D5D2CD] dark:border-neutral-800 shadow-2xl flex flex-col max-h-[85vh] overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[#D5D2CD]/60 dark:border-neutral-800/60 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
|
||||
<Link2 size={15} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--color-ink)] dark:text-white font-serif">
|
||||
Living Block Picker
|
||||
</h3>
|
||||
<p className="text-[10px] text-[var(--color-concrete)] font-medium uppercase tracking-widest">
|
||||
Connecter un bloc en temps réel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded-full text-[var(--color-concrete)] transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-[#D5D2CD]/40 dark:border-neutral-800/40 px-3 bg-black/[0.01]">
|
||||
{(['suggestions', 'search'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.15em] font-extrabold transition-all relative ${
|
||||
activeTab === tab
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-[var(--color-concrete)] hover:text-[var(--color-ink)]/70'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
{tab === 'suggestions' ? <Sparkles size={11} /> : <Search size={11} />}
|
||||
{tab === 'suggestions' ? 'Suggestions IA' : 'Rechercher'}
|
||||
</span>
|
||||
{activeTab === tab && (
|
||||
<motion.div
|
||||
layoutId="pickerTab"
|
||||
className="absolute bottom-0 left-0 right-0 h-[2px] bg-blue-500"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
{activeTab === 'search' && (
|
||||
<div className="p-3 border-b border-[#D5D2CD]/40 dark:border-neutral-800/40 bg-white/40 dark:bg-zinc-950/20">
|
||||
<div className="relative flex items-center">
|
||||
<Search size={14} className="absolute left-3.5 text-[var(--color-concrete)] pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Rechercher un extrait de note..."
|
||||
className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block list */}
|
||||
<div className="flex-1 overflow-y-auto p-3.5 space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-[var(--color-concrete)] text-xs">
|
||||
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
|
||||
Chargement des suggestions...
|
||||
</div>
|
||||
) : displayBlocks.length > 0 ? (
|
||||
displayBlocks.map(block => (
|
||||
<button
|
||||
key={`${block.noteId}-${block.blockId}`}
|
||||
onClick={() => onSelectBlock(block)}
|
||||
className="w-full text-left p-3 rounded-xl border border-transparent hover:border-black/[0.08] hover:bg-white/70 dark:hover:bg-zinc-800/50 bg-white/30 dark:bg-zinc-800/10 transition-all group relative flex gap-3.5"
|
||||
>
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<p className="font-serif italic text-[13px] leading-relaxed text-[var(--color-ink)]/90 dark:text-white/80 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors line-clamp-2">
|
||||
« {block.snippet} »
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--color-concrete)] font-medium">
|
||||
<span className="truncate max-w-[150px] font-semibold">{block.noteTitle}</span>
|
||||
<span className="opacity-40">•</span>
|
||||
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider bg-black/5 dark:bg-white/5 py-0.5 px-1.5 rounded">
|
||||
<Folder size={10} className="opacity-60" />
|
||||
{block.notebookName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{block.score !== undefined && (
|
||||
<div className="shrink-0 flex flex-col justify-center items-end">
|
||||
<span className="text-[10px] font-mono bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-2 py-0.5 rounded-full border border-blue-500/10">
|
||||
{block.score}% affinité
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-[var(--color-concrete)] italic text-xs">
|
||||
{activeTab === 'suggestions'
|
||||
? 'Aucune suggestion disponible pour cette note.'
|
||||
: searchQuery
|
||||
? 'Aucun bloc ne correspond à votre recherche.'
|
||||
: 'Tapez pour rechercher des blocs...'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'motion/react'
|
||||
import { Zap, Lightbulb, Trophy } from 'lucide-react'
|
||||
|
||||
interface BridgeNote {
|
||||
noteId: string
|
||||
@@ -26,13 +28,13 @@ interface BridgeSuggestion {
|
||||
|
||||
interface BridgeNotesDashboardProps {
|
||||
onNoteClick?: (noteId: string) => void
|
||||
clusters: { id: number; name: string; color?: string }[]
|
||||
}
|
||||
|
||||
export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps) {
|
||||
export function BridgeNotesDashboard({ onNoteClick, clusters }: BridgeNotesDashboardProps) {
|
||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
|
||||
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'bridges' | 'suggestions'>('bridges')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
@@ -41,14 +43,12 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Load bridge notes
|
||||
const bridgesRes = await fetch('/api/bridge-notes?details=true')
|
||||
if (bridgesRes.ok) {
|
||||
const bridgesData = await bridgesRes.json()
|
||||
setBridgeNotes(bridgesData.bridgeNotes || [])
|
||||
}
|
||||
|
||||
// Load suggestions
|
||||
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
|
||||
if (suggestionsRes.ok) {
|
||||
const suggestionsData = await suggestionsRes.json()
|
||||
@@ -63,9 +63,7 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
|
||||
|
||||
const generateNewSuggestions = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bridge-notes/suggestions', {
|
||||
method: 'POST'
|
||||
})
|
||||
const res = await fetch('/api/bridge-notes/suggestions', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
@@ -82,8 +80,6 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clusterAId, clusterBId })
|
||||
})
|
||||
|
||||
// Remove from local state
|
||||
setSuggestions(prev => prev.filter(
|
||||
s => !(s.clusterAId === clusterAId && s.clusterBId === clusterBId)
|
||||
))
|
||||
@@ -94,12 +90,12 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="bg-white dark:bg-white/5 rounded-2xl p-6 shadow-sm border border-border/40">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 bg-concrete/20 rounded w-1/3 mb-4" />
|
||||
<div className="space-y-3">
|
||||
<div className="h-20 bg-gray-200 rounded"></div>
|
||||
<div className="h-20 bg-gray-200 rounded"></div>
|
||||
<div className="h-24 bg-concrete/10 rounded-xl" />
|
||||
<div className="h-24 bg-concrete/10 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,164 +103,118 @@ export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{/* Tabs */}
|
||||
<div className="border-b">
|
||||
<nav className="flex -mb-px">
|
||||
<button
|
||||
onClick={() => setActiveTab('bridges')}
|
||||
className={`px-6 py-3 font-medium text-sm ${
|
||||
activeTab === 'bridges'
|
||||
? 'border-b-2 border-blue-500 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Bridge Notes ({bridgeNotes.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('suggestions')}
|
||||
className={`px-6 py-3 font-medium text-sm ${
|
||||
activeTab === 'suggestions'
|
||||
? 'border-b-2 border-blue-500 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Connection Opportunities ({suggestions.length})
|
||||
</button>
|
||||
</nav>
|
||||
<div className="space-y-12">
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
||||
<Trophy size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Bridges</span>
|
||||
</div>
|
||||
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||
<Lightbulb size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Suggestions</span>
|
||||
</div>
|
||||
<div className="text-3xl font-memento-serif font-medium text-ink dark:text-dark-ink">{suggestions.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'bridges' && (
|
||||
<div>
|
||||
{bridgeNotes.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p>No bridge notes found yet</p>
|
||||
<p className="text-sm mt-1">Bridge notes connect different clusters of ideas</p>
|
||||
{/* Bridge Notes Section */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{bridgeNotes.map((bridge) => (
|
||||
<motion.div
|
||||
key={bridge.noteId}
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={() => onNoteClick?.(bridge.noteId)}
|
||||
className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">
|
||||
{bridge.note?.title || 'Untitled'}
|
||||
</h4>
|
||||
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
||||
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{bridgeNotes.map((bridge) => (
|
||||
<div
|
||||
key={bridge.noteId}
|
||||
onClick={() => onNoteClick?.(bridge.noteId)}
|
||||
className="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Bridge Score: {bridge.bridgeScore.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
Connects {bridge.clustersConnected.length} {bridge.clustersConnected.length === 1 ? 'cluster' : 'clusters'}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-900">
|
||||
{bridge.note?.title || 'Untitled'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
{bridge.note?.content?.replace(/<[^>]+>/g, '').slice(0, 150) || 'No content'}
|
||||
</p>
|
||||
{bridge.clusterNames && bridge.clusterNames.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{bridge.clusterNames.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-700"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{bridge.clusterNames?.map((name, i) => {
|
||||
const cluster = clusters.find(c => c.name === name)
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: cluster?.color || '#cbd5e1' }} />
|
||||
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
{bridgeNotes.length === 0 && (
|
||||
<div className="text-xs text-concrete italic p-4">No significant bridge notes found yet. Deepen your research to find new connections.</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{activeTab === 'suggestions' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
AI-suggested ideas to connect your isolated clusters
|
||||
</p>
|
||||
{/* Connection Suggestions */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-6 px-1">
|
||||
<Lightbulb size={16} className="text-indigo-500" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((s, idx) => (
|
||||
<div key={`${s.clusterAId}-${s.clusterBId}`} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex -space-x-2">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</div>
|
||||
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">
|
||||
Bridging {s.clusterAName} & {s.clusterBName}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-base font-memento-serif font-medium text-ink dark:text-dark-ink mb-2">{s.suggestedTitle}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.suggestedContent}</p>
|
||||
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
|
||||
<Zap size={12} className="shrink-0" />
|
||||
<span>{s.justification}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismissSuggestion(s.clusterAId, s.clusterBId)}
|
||||
className="ml-4 p-2 text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-concrete/10 rounded-lg transition-colors"
|
||||
title="Dismiss suggestion"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{suggestions.length === 0 && !loading && (
|
||||
<div className="text-center py-8 text-concrete">
|
||||
<Lightbulb size={24} className="mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No connection suggestions yet</p>
|
||||
<p className="text-xs mt-1">All your clusters may already be connected!</p>
|
||||
<button
|
||||
onClick={generateNewSuggestions}
|
||||
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
className="mt-4 px-4 py-2 bg-indigo-500 text-white text-xs rounded-lg hover:bg-indigo-600 transition-colors"
|
||||
>
|
||||
Generate New
|
||||
Generate Suggestions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{suggestions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<p>No connection suggestions yet</p>
|
||||
<p className="text-sm mt-1">All your clusters may already be connected!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={`${suggestion.clusterAId}-${suggestion.clusterBId}`}
|
||||
className="border rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">💡</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{suggestion.suggestedTitle}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">#{index + 1}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 mb-2">
|
||||
<span className="px-2 py-0.5 rounded bg-blue-100 text-blue-700">
|
||||
{suggestion.clusterAName}
|
||||
</span>
|
||||
<span>↔</span>
|
||||
<span className="px-2 py-0.5 rounded bg-purple-100 text-purple-700">
|
||||
{suggestion.clusterBName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
{suggestion.suggestedContent}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
"{suggestion.justification}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => dismissSuggestion(suggestion.clusterAId, suggestion.clusterBId)}
|
||||
className="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Dismiss suggestion"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sparkles, ThumbsUp, ThumbsDown, GitMerge, X } from 'lucide-react'
|
||||
import { Columns2, GitMerge, X, ExternalLink } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Note } from '@/lib/types'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
@@ -13,54 +12,40 @@ interface ComparisonModalProps {
|
||||
onClose: () => void
|
||||
notes: Array<Partial<Note>>
|
||||
similarity?: number
|
||||
onOpenNote?: (noteId: string) => void
|
||||
onMergeNotes?: (noteIds: string[]) => void
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function openNoteInNewTab(noteId: string) {
|
||||
window.open(`/home?openNote=${encodeURIComponent(noteId)}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
export function ComparisonModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
notes,
|
||||
similarity,
|
||||
onOpenNote,
|
||||
onMergeNotes
|
||||
onMergeNotes,
|
||||
}: ComparisonModalProps) {
|
||||
const { t } = useLanguage()
|
||||
const [feedback, setFeedback] = useState<'thumbs_up' | 'thumbs_down' | null>(null)
|
||||
|
||||
const handleFeedback = async (type: 'thumbs_up' | 'thumbs_down') => {
|
||||
setFeedback(type)
|
||||
try {
|
||||
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
|
||||
if (noteIds.length >= 2) {
|
||||
await fetch('/api/ai/echo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'feedback', noteIds, feedback: type })
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// silent — feedback is best-effort
|
||||
}
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const getNoteColor = (index: number) => {
|
||||
const colors = [
|
||||
'border-primary/20 dark:border-primary/30 hover:border-primary/30 dark:hover:border-primary/40',
|
||||
'border-purple-200 dark:border-purple-800 hover:border-purple-300 dark:hover:border-purple-700',
|
||||
'border-green-200 dark:border-green-800 hover:border-green-300 dark:hover:border-green-700'
|
||||
'border-indigo-200/80 dark:border-indigo-800/80',
|
||||
'border-purple-200/80 dark:border-purple-800/80',
|
||||
'border-emerald-200/80 dark:border-emerald-800/80',
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
const getTitleColor = (index: number) => {
|
||||
const colors = [
|
||||
'text-primary dark:text-primary-foreground',
|
||||
'text-purple-600 dark:text-purple-400',
|
||||
'text-green-600 dark:text-green-400'
|
||||
'text-indigo-700 dark:text-indigo-300',
|
||||
'text-purple-700 dark:text-purple-300',
|
||||
'text-emerald-700 dark:text-emerald-300',
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
@@ -72,133 +57,107 @@ export function ComparisonModal({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"max-h-[90vh] overflow-hidden flex flex-col p-0",
|
||||
maxModalWidth
|
||||
)}
|
||||
className={cn('max-h-[90vh] overflow-hidden flex flex-col p-0', maxModalWidth)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10">
|
||||
<Columns2 className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
{t('memoryEcho.comparison.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.comparison.similarityInfo', { similarity: similarityPercentage })}
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
{similarityPercentage > 0
|
||||
? t('memoryEcho.comparison.similarityInfo', { similarity: similarityPercentage })
|
||||
: t('memoryEcho.comparison.subtitle')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t('memoryEcho.editorSection.close')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insight Section */}
|
||||
<div className="px-6 py-3 border-b border-border/60 bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('memoryEcho.comparison.stayOnCurrentNote')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{similarityPercentage >= 80 && (
|
||||
<div className="px-6 py-4 bg-amber-50 dark:bg-amber-950/20 border-b dark:border-zinc-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('memoryEcho.comparison.highSimilarityInsight')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-b border-border/60">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('memoryEcho.comparison.highSimilarityInsight')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes Grid */}
|
||||
<div className={cn(
|
||||
"flex-1 overflow-y-auto p-6",
|
||||
notes.length === 2 ? "grid grid-cols-2 gap-6" : "grid grid-cols-3 gap-4"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto p-6',
|
||||
notes.length === 2 ? 'grid grid-cols-1 md:grid-cols-2 gap-6' : 'grid grid-cols-1 md:grid-cols-3 gap-4'
|
||||
)}
|
||||
>
|
||||
{notes.map((note, index) => {
|
||||
const title = note.title || t('memoryEcho.comparison.untitled')
|
||||
const noteColor = getNoteColor(index)
|
||||
const titleColor = getTitleColor(index)
|
||||
const plainContent = stripHtml(note.content || '')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={note.id || index}
|
||||
onClick={() => {
|
||||
if (onOpenNote && note.id) {
|
||||
onOpenNote(note.id)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer border dark:border-zinc-700 rounded-lg p-4 transition-all hover:shadow-md",
|
||||
noteColor
|
||||
'border rounded-xl p-4 bg-card flex flex-col',
|
||||
getNoteColor(index)
|
||||
)}
|
||||
>
|
||||
<h3 className={cn("font-semibold text-lg mb-3", titleColor)}>
|
||||
<h3 className={cn('font-semibold text-lg mb-3', getTitleColor(index))}>
|
||||
{title}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-8 whitespace-pre-wrap">
|
||||
{note.content}
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t dark:border-zinc-700">
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
{t('memoryEcho.comparison.clickToView')}
|
||||
<span className="transform rotate-[-45deg]">→</span>
|
||||
</p>
|
||||
<div className="text-sm text-muted-foreground line-clamp-[14] whitespace-pre-wrap flex-1">
|
||||
{plainContent}
|
||||
</div>
|
||||
{note.id && (
|
||||
<div className="mt-4 pt-3 border-t border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openNoteInNewTab(note.id!)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t('memoryEcho.editorSection.openInEditor')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer - Feedback + Actions */}
|
||||
<div className="px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('memoryEcho.comparison.helpfulQuestion')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={feedback === 'thumbs_up' ? 'default' : 'outline'}
|
||||
onClick={() => handleFeedback('thumbs_up')}
|
||||
className={cn(
|
||||
feedback === 'thumbs_up' && "bg-green-600 hover:bg-green-700 text-white"
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.comparison.helpful')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={feedback === 'thumbs_down' ? 'default' : 'outline'}
|
||||
onClick={() => handleFeedback('thumbs_down')}
|
||||
className={cn(
|
||||
feedback === 'thumbs_down' && "bg-red-600 hover:bg-red-700 text-white"
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.comparison.notHelpful')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{onMergeNotes && notes.length >= 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
onClick={() => {
|
||||
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
|
||||
onMergeNotes(noteIds)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<GitMerge className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.editorSection.mergeAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-border/60 bg-muted/30 flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('memoryEcho.editorSection.backToNote')}
|
||||
</Button>
|
||||
{onMergeNotes && notes.length >= 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
onClick={() => {
|
||||
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
|
||||
onMergeNotes(noteIds)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<GitMerge className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.editorSection.mergeAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-i
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
import { toast } from 'sonner'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
|
||||
// ── Custom Toast Helper ──────────────────────────────────────────────────────
|
||||
const mToast = {
|
||||
@@ -195,6 +196,7 @@ export function ContextualAIChat({
|
||||
}: ContextualAIChatProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const webSearchAvailable = useWebSearchAvailable()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'chat' | 'actions' | 'resource'>('actions')
|
||||
const [selectedTone, setSelectedTone] = useState('professional')
|
||||
@@ -282,6 +284,11 @@ export function ContextualAIChat({
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || isLoading) return
|
||||
|
||||
// GDPR AI Consent Check
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setInput('')
|
||||
try {
|
||||
await sendMessage({ text }, { body: buildChatBody() })
|
||||
@@ -293,6 +300,10 @@ export function ContextualAIChat({
|
||||
|
||||
// ── Action execution ────────────────────────────────────────────────────────
|
||||
const handleAction = async (action: ActionDef, targetLang?: string) => {
|
||||
// GDPR AI Consent Check
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
if (action.isImageAction) {
|
||||
if (!noteImages || noteImages.length === 0) {
|
||||
mToast.error(t('ai.noImagesError') || 'Aucune image dans cette note')
|
||||
@@ -511,6 +522,10 @@ export function ContextualAIChat({
|
||||
return
|
||||
}
|
||||
|
||||
// GDPR AI Consent Check
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setResourceEnriching(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/enrich-from-resource', {
|
||||
@@ -552,6 +567,10 @@ export function ContextualAIChat({
|
||||
return
|
||||
}
|
||||
|
||||
// GDPR AI Consent Check
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setResourceEnriching(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/enrich-from-resource', {
|
||||
|
||||
@@ -1,255 +1 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChevronDown, ChevronUp, Sparkles, Eye, ArrowRight, Link2, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
|
||||
interface ConnectionData {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
createdAt: Date
|
||||
similarity: number
|
||||
daysApart: number
|
||||
}
|
||||
|
||||
interface ConnectionsResponse {
|
||||
connections: ConnectionData[]
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface EditorConnectionsSectionProps {
|
||||
noteId: string
|
||||
onOpenNote?: (noteId: string) => void
|
||||
onCompareNotes?: (noteIds: string[]) => void
|
||||
onMergeNotes?: (noteIds: string[]) => void
|
||||
}
|
||||
|
||||
export function EditorConnectionsSection({
|
||||
noteId,
|
||||
onOpenNote,
|
||||
onCompareNotes,
|
||||
onMergeNotes
|
||||
}: EditorConnectionsSectionProps) {
|
||||
const { t } = useLanguage()
|
||||
const [connections, setConnections] = useState<ConnectionData[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConnections = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=10`)
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch connections')
|
||||
}
|
||||
|
||||
const data: ConnectionsResponse = await res.json()
|
||||
setConnections(data.connections)
|
||||
|
||||
// Show section if there are connections
|
||||
if (data.connections.length > 0) {
|
||||
setIsVisible(true)
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[EditorConnectionsSection] Failed to fetch:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchConnections()
|
||||
}, [noteId])
|
||||
|
||||
// Don't render if no connections or if dismissed
|
||||
if (!isVisible || (connections.length === 0 && !isLoading)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t dark:border-zinc-700 pt-4">
|
||||
{/* Header with toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer select-none group"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Sparkles className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{t('memoryEcho.editorSection.title', { count: connections.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Dismiss all connections for this note
|
||||
try {
|
||||
await Promise.all(
|
||||
connections.map(conn =>
|
||||
fetch('/api/ai/echo/dismiss', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
noteId: noteId,
|
||||
connectedNoteId: conn.noteId
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
setIsVisible(false)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to dismiss connections:', error)
|
||||
}
|
||||
}}
|
||||
title={t('memoryEcho.editorSection.close') }
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsExpanded(!isExpanded)
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections list */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-sm text-gray-500">
|
||||
{t('memoryEcho.editorSection.loading')}
|
||||
</div>
|
||||
) : (
|
||||
connections.map((conn) => {
|
||||
const similarityPercentage = Math.round(conn.similarity * 100)
|
||||
const title = conn.title || t('memoryEcho.comparison.untitled')
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conn.noteId}
|
||||
className="border dark:border-zinc-700 rounded-lg p-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
|
||||
{title}
|
||||
</h4>
|
||||
<span className="ml-2 text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
|
||||
{similarityPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">
|
||||
{conn.content}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={() => onOpenNote?.(conn.noteId)}
|
||||
>
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
{t('memoryEcho.editorSection.view')}
|
||||
</Button>
|
||||
|
||||
{onCompareNotes && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={() => onCompareNotes([noteId, conn.noteId])}
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 mr-1" />
|
||||
{t('memoryEcho.editorSection.compare')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onMergeNotes && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={() => onMergeNotes([noteId, conn.noteId])}
|
||||
>
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
{t('memoryEcho.editorSection.merge')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer actions */}
|
||||
{isExpanded && connections.length > 1 && (
|
||||
<div className="mt-3 flex items-center gap-2 pt-2 border-t dark:border-zinc-700">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
if (onCompareNotes) {
|
||||
const allIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
|
||||
onCompareNotes([noteId, ...allIds])
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('memoryEcho.editorSection.compareAll')}
|
||||
</Button>
|
||||
|
||||
{onMergeNotes && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
const allIds = connections.map(c => c.noteId)
|
||||
onMergeNotes([noteId, ...allIds])
|
||||
}}
|
||||
>
|
||||
{t('memoryEcho.editorSection.mergeAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export { MemoryEchoSection, EditorConnectionsSection } from './memory-echo-section'
|
||||
|
||||
@@ -9,6 +9,7 @@ import { X, Link2, Sparkles, Edit, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Note } from '@/lib/types'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
|
||||
interface FusionModalProps {
|
||||
isOpen: boolean
|
||||
@@ -31,6 +32,7 @@ export function FusionModal({
|
||||
onConfirmFusion
|
||||
}: FusionModalProps) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [selectedNoteIds, setSelectedNoteIds] = useState<string[]>(notes.filter(n => n.id).map(n => n.id!))
|
||||
const [customPrompt, setCustomPrompt] = useState('')
|
||||
const [fusionPreview, setFusionPreview] = useState('')
|
||||
@@ -52,8 +54,9 @@ export function FusionModal({
|
||||
setFusionPreview('')
|
||||
|
||||
try {
|
||||
|
||||
// Call AI API to generate fusion
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
const res = await fetch('/api/ai/echo/fusion', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -81,7 +84,7 @@ export function FusionModal({
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}, [selectedNoteIds, customPrompt])
|
||||
}, [selectedNoteIds, customPrompt, requestAiConsent])
|
||||
|
||||
// Auto-generate fusion preview when modal opens with selected notes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Check,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import { Notebook } from '@/lib/types'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||
import { ChevronRight, ChevronDown, Folder, Check } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AnimatePresence } from 'motion/react'
|
||||
import {
|
||||
getNotebookPickerPosition,
|
||||
NotebookHierarchyPanel,
|
||||
type NotebookPickerItem,
|
||||
} from '@/components/notebook-hierarchy-panel'
|
||||
|
||||
interface HierarchicalNotebookSelectorProps {
|
||||
notebooks: { id: string; name: string; icon?: string | null; parentId?: string | null; trashedAt?: Date | string | null }[]
|
||||
notebooks: NotebookPickerItem[]
|
||||
selectedId: string | null
|
||||
onSelect: (id: string) => void
|
||||
className?: string
|
||||
@@ -33,35 +30,29 @@ export function HierarchicalNotebookSelector({
|
||||
size = 'default',
|
||||
}: HierarchicalNotebookSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties | undefined>()
|
||||
|
||||
const getDropdownStyle = useCallback((): React.CSSProperties => {
|
||||
if (!triggerRef.current) return { position: 'fixed', top: 0, left: 0, width: 280, zIndex: 9999 }
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
const dropdownHeight = 350
|
||||
const viewportHeight = window.innerHeight
|
||||
const wouldOverflowBottom = rect.bottom + 8 + dropdownHeight > viewportHeight
|
||||
if (dropUp || wouldOverflowBottom) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: window.innerHeight - rect.top + 8,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 280),
|
||||
zIndex: 9999,
|
||||
}
|
||||
}
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: rect.bottom + 8,
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 280),
|
||||
zIndex: 9999,
|
||||
}
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
setPanelStyle(getNotebookPickerPosition(triggerRef.current.getBoundingClientRect(), { preferDropUp: dropUp }))
|
||||
}, [dropUp])
|
||||
|
||||
const selectedNotebook = notebooks.find(nb => nb.id === selectedId)
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPanelStyle(undefined)
|
||||
return
|
||||
}
|
||||
updatePosition()
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
window.addEventListener('resize', updatePosition)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
}
|
||||
}, [isOpen, updatePosition])
|
||||
|
||||
const selectedNotebook = notebooks.find((nb) => nb.id === selectedId)
|
||||
|
||||
const path = useMemo(() => {
|
||||
if (!selectedNotebook) return []
|
||||
@@ -70,96 +61,18 @@ export function HierarchicalNotebookSelector({
|
||||
while (current) {
|
||||
trail.unshift(current)
|
||||
if (!current.parentId) break
|
||||
const parent = notebooks.find(nb => nb.id === current!.parentId)
|
||||
const parent = notebooks.find((nb) => nb.id === current!.parentId)
|
||||
if (!parent) break
|
||||
current = parent
|
||||
}
|
||||
return trail
|
||||
}, [selectedNotebook, notebooks])
|
||||
|
||||
const toggleExpand = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
const next = new Set(expandedIds)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
setExpandedIds(next)
|
||||
}
|
||||
|
||||
const renderTree = (parentId: string | null | undefined, level = 0): React.ReactNode => {
|
||||
const children = notebooks.filter(
|
||||
nb => (nb.parentId ?? null) === (parentId ?? null)
|
||||
)
|
||||
if (children.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={level > 0 ? 'ml-4 border-l border-border/40 pl-2' : ''}>
|
||||
{children.map(notebook => {
|
||||
const isExpanded = expandedIds.has(notebook.id) || searchQuery.length > 0
|
||||
const hasChildren = notebooks.some(nb => nb.parentId === notebook.id)
|
||||
const isSelected = selectedId === notebook.id
|
||||
|
||||
if (searchQuery && !notebook.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
const hasMatchingChild = (id: string): boolean => {
|
||||
const kids = notebooks.filter(nb => nb.parentId === id)
|
||||
return kids.some(nb => nb.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(nb.id))
|
||||
}
|
||||
if (!hasMatchingChild(notebook.id)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={notebook.id} className="select-none">
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelect(notebook.id)
|
||||
if (!searchQuery) setIsOpen(false)
|
||||
}}
|
||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
|
||||
${isSelected ? 'bg-brand-accent/15 text-brand-accent font-bold dark:bg-brand-accent/20' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
|
||||
>
|
||||
<div className="w-4 flex items-center justify-center">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => toggleExpand(e, notebook.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`p-1 rounded ${isSelected ? 'bg-brand-accent/25 dark:bg-brand-accent/30' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}>
|
||||
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</div>
|
||||
|
||||
<span className="text-[13px] truncate flex-1">{notebook.name}</span>
|
||||
|
||||
{isSelected && <Check size={14} className="opacity-60 shrink-0" />}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{renderTree(notebook.id, level + 1)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className={`w-full bg-card dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-brand-accent/10 focus:border-brand-accent/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`}
|
||||
>
|
||||
<Folder size={size === 'sm' ? 14 : 16} className="text-brand-accent/70 shrink-0" />
|
||||
@@ -179,56 +92,36 @@ export function HierarchicalNotebookSelector({
|
||||
<span className="text-concrete italic">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown size={14} className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && typeof window !== 'undefined' && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9998]" onClick={() => setIsOpen(false)} />
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="notebook-dropdown"
|
||||
initial={{ opacity: 0, y: dropUp ? -8 : 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: dropUp ? -8 : 8, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={getDropdownStyle()}
|
||||
className="bg-card border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col"
|
||||
>
|
||||
<div className="p-3 border-b border-border/40 bg-card/50 dark:bg-white/5">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-card border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-brand-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto custom-scrollbar p-2">
|
||||
{renderTree(null)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/40 bg-card/30 dark:bg-white/5 flex justify-between items-center px-4">
|
||||
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||
{notebooks.length} notebooks
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-[10px] font-bold text-brand-accent hover:underline"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
{isOpen &&
|
||||
typeof window !== 'undefined' &&
|
||||
panelStyle &&
|
||||
createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9998]" onClick={() => setIsOpen(false)} aria-hidden />
|
||||
<AnimatePresence>
|
||||
<NotebookHierarchyPanel
|
||||
key="hierarchical-notebook-selector"
|
||||
notebooks={notebooks}
|
||||
selectedId={selectedId}
|
||||
onSelect={(id) => {
|
||||
onSelect(id)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
onClose={() => setIsOpen(false)}
|
||||
searchPlaceholder={placeholder}
|
||||
footerLabel={`${notebooks.filter((nb) => !nb.trashedAt).length} notebooks`}
|
||||
style={panelStyle}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,21 @@ import React, { useState, useEffect, useCallback, useRef, useTransition, useMemo
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote } from '@/app/actions/notes'
|
||||
import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||||
import { getAllNotes, searchNotes, enableNoteHistory, getNoteById, createNote, deleteNote, togglePin, toggleArchive, updateNote, updateFullOrderWithoutRevalidation } from '@/app/actions/notes'
|
||||
import { NotesListViews, type NotesLayoutMode, type NotesViewType } from '@/components/notes-list-views'
|
||||
import {
|
||||
NOTES_LAYOUT_STORAGE_KEY,
|
||||
NOTES_VIEW_TYPE_STORAGE_KEY,
|
||||
parseNotesLayoutMode,
|
||||
parseNotesViewType,
|
||||
setNotesLayoutPreference,
|
||||
setNotesViewTypePreference,
|
||||
} from '@/lib/notes-view-preference'
|
||||
|
||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu } from 'lucide-react'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu, LayoutGrid, List, Table } from 'lucide-react'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
@@ -58,9 +64,16 @@ type InitialSettings = {
|
||||
interface HomeClientProps {
|
||||
initialNotes: Note[]
|
||||
initialSettings: InitialSettings
|
||||
initialLayoutMode?: NotesLayoutMode
|
||||
initialViewType?: NotesViewType
|
||||
}
|
||||
|
||||
export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
export function HomeClient({
|
||||
initialNotes,
|
||||
initialSettings,
|
||||
initialLayoutMode = 'list',
|
||||
initialViewType = 'notes',
|
||||
}: HomeClientProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
@@ -86,9 +99,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const notesRef = useRef(notes)
|
||||
notesRef.current = notes
|
||||
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
||||
const { refreshNotes } = useRefresh()
|
||||
const { labels, notebooks, refreshNotebooks } = useNotebooks()
|
||||
const labelsRef = useRef(labels)
|
||||
labelsRef.current = labels
|
||||
const initialNotesRef = useRef(initialNotes)
|
||||
initialNotesRef.current = initialNotes
|
||||
const { setControls } = useEditorUI()
|
||||
|
||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
||||
@@ -99,6 +114,41 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
|
||||
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||||
const [viewType, setViewType] = useState<NotesViewType>(initialViewType)
|
||||
const [layoutMode, setLayoutMode] = useState<NotesLayoutMode>(initialLayoutMode)
|
||||
|
||||
useEffect(() => {
|
||||
const storedLayout = parseNotesLayoutMode(localStorage.getItem(NOTES_LAYOUT_STORAGE_KEY))
|
||||
const storedViewType = parseNotesViewType(localStorage.getItem(NOTES_VIEW_TYPE_STORAGE_KEY))
|
||||
if (storedLayout !== initialLayoutMode) {
|
||||
setLayoutMode(storedLayout)
|
||||
setNotesLayoutPreference(storedLayout)
|
||||
}
|
||||
if (storedViewType !== initialViewType) {
|
||||
setViewType(storedViewType)
|
||||
setNotesViewTypePreference(storedViewType)
|
||||
}
|
||||
}, [initialLayoutMode, initialViewType])
|
||||
|
||||
useEffect(() => {
|
||||
setNotesViewTypePreference(viewType)
|
||||
}, [viewType])
|
||||
|
||||
useEffect(() => {
|
||||
setNotesLayoutPreference(layoutMode)
|
||||
}, [layoutMode])
|
||||
|
||||
useEffect(() => {
|
||||
const onLayoutChange = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ layout?: NotesLayoutMode }>).detail?.layout
|
||||
if (detail === 'grid' || detail === 'list' || detail === 'table') {
|
||||
setLayoutMode(detail)
|
||||
setViewType('notes')
|
||||
}
|
||||
}
|
||||
window.addEventListener('memento-notes-layout-change', onLayoutChange)
|
||||
return () => window.removeEventListener('memento-notes-layout-change', onLayoutChange)
|
||||
}, [])
|
||||
|
||||
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
|
||||
useEffect(() => {
|
||||
@@ -112,12 +162,86 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
}, [searchParams, router])
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
|
||||
const fetchNotesForCurrentView = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
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 sharedOnly = searchParams.get('shared') === '1'
|
||||
const remindersOnly = searchParams.get('reminders') === '1'
|
||||
|
||||
if (!options?.silent) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
let allNotes = search
|
||||
? await searchNotes(search, true, notebook || undefined)
|
||||
: await getAllNotes(false, notebook || undefined)
|
||||
|
||||
if (sharedOnly) {
|
||||
allNotes = allNotes.filter((note: Note) => note._isShared)
|
||||
} else if (remindersOnly) {
|
||||
allNotes = allNotes.filter((note: Note) => note.reminder !== null)
|
||||
} else if (!notebook && !search) {
|
||||
allNotes = allNotes.filter((note: Note) => !note.notebookId && !note._isShared)
|
||||
}
|
||||
|
||||
if (labelFilter.length > 0) {
|
||||
allNotes = allNotes.filter((note: Note) =>
|
||||
labelFilter.every((label: string) => note.labels?.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labelsRef.current
|
||||
.filter((label) => label.color === colorFilter)
|
||||
.map((label) => label.name)
|
||||
allNotes = allNotes.filter((note: Note) =>
|
||||
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
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: Note) => n.isPinned))
|
||||
setIsLoading(false)
|
||||
},
|
||||
[searchParams]
|
||||
)
|
||||
|
||||
const filterNotesForCurrentView = useCallback((source: Note[]) => {
|
||||
const notebook = searchParams.get('notebook')
|
||||
const sharedOnly = searchParams.get('shared') === '1'
|
||||
const remindersOnly = searchParams.get('reminders') === '1'
|
||||
|
||||
if (notebook) {
|
||||
return source.filter((n) => n.notebookId === notebook && !n._isShared)
|
||||
}
|
||||
if (sharedOnly) {
|
||||
return source.filter((n) => n._isShared)
|
||||
}
|
||||
if (remindersOnly) {
|
||||
return source.filter((n) => n.reminder !== null)
|
||||
}
|
||||
return source.filter((n) => !n.notebookId && !n._isShared)
|
||||
}, [searchParams])
|
||||
|
||||
const handleNoteCreated = useCallback((note: Note) => {
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
if (search) {
|
||||
void fetchNotesForCurrentView({ silent: true })
|
||||
emitNoteChange({ type: 'created', note })
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
@@ -135,11 +259,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
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)
|
||||
@@ -150,6 +269,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
return [...pinnedNotes, note, ...unpinnedNotes]
|
||||
}
|
||||
})
|
||||
emitNoteChange({ type: 'created', note })
|
||||
|
||||
if (!note.notebookId) {
|
||||
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
||||
@@ -157,7 +277,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
setNotebookSuggestion({ noteId: note.id, content: note.content || '' })
|
||||
}
|
||||
}
|
||||
}, [searchParams, labels, router])
|
||||
}, [searchParams, labels, fetchNotesForCurrentView])
|
||||
|
||||
// Always fetch fresh from server — avoids stale state after a save regardless of
|
||||
// whether the notes list has re-fetched yet.
|
||||
@@ -210,7 +330,134 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
||||
}, [])
|
||||
|
||||
useReminderCheck(notes)
|
||||
const patchNoteInList = useCallback((noteId: string, patch: Partial<Note>) => {
|
||||
setNotes((prev) => prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n)))
|
||||
}, [])
|
||||
|
||||
const removeNoteFromList = useCallback((noteId: string) => {
|
||||
setNotes((prev) => prev.filter((n) => n.id !== noteId))
|
||||
setEditingNote((prev) => (prev?.note.id === noteId ? null : prev))
|
||||
}, [])
|
||||
|
||||
const noteVisibleInCurrentView = useCallback(
|
||||
(note: Pick<Note, 'notebookId' | '_isShared'>) => {
|
||||
const notebook = searchParams.get('notebook')
|
||||
const sharedOnly = searchParams.get('shared') === '1'
|
||||
if (sharedOnly) return !!note._isShared
|
||||
if (notebook) return note.notebookId === notebook && !note._isShared
|
||||
return !note.notebookId && !note._isShared
|
||||
},
|
||||
[searchParams]
|
||||
)
|
||||
|
||||
const handleTogglePin = useCallback(
|
||||
async (note: Note) => {
|
||||
const nextPinned = !note.isPinned
|
||||
patchNoteInList(note.id, { isPinned: nextPinned })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, isPinned: nextPinned } })
|
||||
try {
|
||||
await togglePin(note.id, nextPinned, { skipRevalidation: true })
|
||||
} catch {
|
||||
patchNoteInList(note.id, { isPinned: !nextPinned })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, isPinned: !nextPinned } })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
},
|
||||
[patchNoteInList, t]
|
||||
)
|
||||
|
||||
const handleDeleteNoteFromList = useCallback(
|
||||
async (note: Note) => {
|
||||
removeNoteFromList(note.id)
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
emitNoteChange({ type: 'created', note })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
},
|
||||
[removeNoteFromList, t]
|
||||
)
|
||||
|
||||
const handleArchiveNoteFromList = useCallback(
|
||||
async (note: Note) => {
|
||||
const nextArchived = !note.isArchived
|
||||
if (nextArchived) removeNoteFromList(note.id)
|
||||
else patchNoteInList(note.id, { isArchived: nextArchived })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, isArchived: nextArchived } })
|
||||
try {
|
||||
await toggleArchive(note.id, nextArchived, { skipRevalidation: true })
|
||||
toast.success(
|
||||
nextArchived ? (t('notes.archived') || 'Archivée') : (t('notes.unarchived') || 'Désarchivée')
|
||||
)
|
||||
} catch {
|
||||
if (nextArchived) setNotes((prev) => [note, ...prev])
|
||||
else patchNoteInList(note.id, { isArchived: !nextArchived })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
},
|
||||
[patchNoteInList, removeNoteFromList, t]
|
||||
)
|
||||
|
||||
const handleMoveNoteToNotebook = useCallback(
|
||||
async (note: Note, notebookId: string | null) => {
|
||||
const moved = { ...note, notebookId }
|
||||
if (noteVisibleInCurrentView(moved)) {
|
||||
patchNoteInList(note.id, { notebookId })
|
||||
} else {
|
||||
removeNoteFromList(note.id)
|
||||
}
|
||||
emitNoteChange({ type: 'updated', note: moved })
|
||||
try {
|
||||
await updateNote(note.id, { notebookId }, { skipRevalidation: true })
|
||||
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
|
||||
} catch {
|
||||
if (noteVisibleInCurrentView(note)) {
|
||||
patchNoteInList(note.id, { notebookId: note.notebookId ?? null })
|
||||
} else {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
}
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
},
|
||||
[noteVisibleInCurrentView, patchNoteInList, removeNoteFromList, t]
|
||||
)
|
||||
|
||||
const handleNoteContentPatch = useCallback((noteId: string, patch: Partial<Note>) => {
|
||||
setNotes((prev) => {
|
||||
const next = prev.map((n) => (n.id === noteId ? { ...n, ...patch } : n))
|
||||
const updated = next.find((n) => n.id === noteId)
|
||||
if (updated) emitNoteChange({ type: 'updated', note: updated })
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleNoteIllustrationGenerated = useCallback(async (noteId: string) => {
|
||||
const fresh = await getNoteById(noteId)
|
||||
if (!fresh) return
|
||||
patchNoteInList(noteId, fresh)
|
||||
emitNoteChange({ type: 'updated', note: fresh })
|
||||
}, [patchNoteInList])
|
||||
|
||||
const handleGridReorder = useCallback(
|
||||
async (orderedIds: string[]) => {
|
||||
setSortOrder('manual')
|
||||
setNotes((prev) => {
|
||||
const orderMap = new Map(orderedIds.map((id, index) => [id, index]))
|
||||
return prev.map((n) => (orderMap.has(n.id) ? { ...n, order: orderMap.get(n.id)! } : n))
|
||||
})
|
||||
try {
|
||||
await updateFullOrderWithoutRevalidation(orderedIds)
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
},
|
||||
[t],
|
||||
)
|
||||
|
||||
|
||||
// Garder openNote dans l'URL tant que l'éditeur est ouvert → le sidebar peut surligner la note (comme activeNoteId dans la ref.)
|
||||
useEffect(() => {
|
||||
@@ -248,86 +495,27 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
return () => window.removeEventListener('label-deleted', handler)
|
||||
}, [])
|
||||
|
||||
const prevRefreshKey = useRef(refreshKey)
|
||||
|
||||
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 sharedOnly = searchParams.get('shared') === '1'
|
||||
const remindersOnly = searchParams.get('reminders') === '1'
|
||||
const hasActiveFilter = !!(search || labelFilter.length > 0 || colorFilter || sharedOnly || remindersOnly)
|
||||
|
||||
const isBackgroundRefresh = refreshKey > prevRefreshKey.current
|
||||
prevRefreshKey.current = refreshKey
|
||||
|
||||
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
|
||||
|
||||
const load = async () => {
|
||||
if (!isBackgroundRefresh) {
|
||||
setIsLoading(true)
|
||||
}
|
||||
let allNotes = search
|
||||
? await searchNotes(search, true, notebook || undefined)
|
||||
: await getAllNotes(false, notebook || undefined)
|
||||
|
||||
const sharedOnly = searchParams.get('shared') === '1'
|
||||
const remindersOnly = searchParams.get('reminders') === '1'
|
||||
|
||||
if (sharedOnly) {
|
||||
allNotes = allNotes.filter((note: any) => note._isShared)
|
||||
} else if (remindersOnly) {
|
||||
allNotes = allNotes.filter((note: any) => note.reminder !== null)
|
||||
} else if (!notebook && !search) {
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId && !note._isShared)
|
||||
}
|
||||
|
||||
if (labelFilter.length > 0) {
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
labelFilter.every((label: string) => note.labels?.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
if (hasActiveFilter || notebook) {
|
||||
void fetchNotesForCurrentView()
|
||||
return
|
||||
}
|
||||
|
||||
if (refreshKey > 0 || hasActiveFilter) {
|
||||
const cancelled = { value: false }
|
||||
load().then(() => { if (cancelled.value) return })
|
||||
return () => { cancelled.value = true }
|
||||
} else {
|
||||
let filtered = initialNotes
|
||||
const sharedOnly = searchParams.get('shared') === '1'
|
||||
const remindersOnly = searchParams.get('reminders') === '1'
|
||||
if (notebook) {
|
||||
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
||||
} else if (sharedOnly) {
|
||||
filtered = initialNotes.filter((n: any) => n._isShared)
|
||||
} else if (remindersOnly) {
|
||||
filtered = initialNotes.filter((n: any) => n.reminder !== null)
|
||||
} else {
|
||||
filtered = initialNotes.filter((n: any) => !n.notebookId && !n._isShared)
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
}, [searchParams, refreshKey])
|
||||
const filtered = filterNotesForCurrentView(initialNotesRef.current)
|
||||
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))
|
||||
}, [searchParams, fetchNotesForCurrentView, filterNotesForCurrentView])
|
||||
|
||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||
|
||||
@@ -437,19 +625,13 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
params.delete('openNote')
|
||||
const qs = params.toString()
|
||||
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
||||
// Invalidate notes cache and trigger refresh
|
||||
refreshNotes(searchParams.get('notebook') || null)
|
||||
}, [refreshNotes, router, searchParams])
|
||||
}, [router, searchParams])
|
||||
|
||||
// Called by NoteEditor when a save succeeds — update local state immediately
|
||||
// so the user sees fresh data if they reopen the note before getAllNotes() completes
|
||||
const handleNoteSaved = useCallback((savedNote: Note) => {
|
||||
setNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n))
|
||||
setPinnedNotes(prev => prev.map(n => n.id === savedNote.id ? { ...n, ...savedNote } : n))
|
||||
setEditingNote(prev => prev?.note.id === savedNote.id ? { ...prev, note: savedNote } : prev)
|
||||
// Refresh sidebar note titles so the new title appears immediately
|
||||
refreshNotes(savedNote.notebookId || null)
|
||||
}, [refreshNotes])
|
||||
setNotes((prev) => prev.map((n) => (n.id === savedNote.id ? { ...n, ...savedNote } : n)))
|
||||
setEditingNote((prev) => (prev?.note.id === savedNote.id ? { ...prev, note: savedNote } : prev))
|
||||
emitNoteChange({ type: 'updated', note: savedNote })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -626,7 +808,78 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewType('notes')}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
|
||||
viewType === 'notes'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('notes.viewNotes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewType('tasks')}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all',
|
||||
viewType === 'tasks'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{t('notes.viewTasks')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewType === 'notes' && (
|
||||
<div className="bg-foreground/[0.03] dark:bg-white/[0.04] p-0.5 rounded-full flex border border-border/30 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutMode('grid')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'grid'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('notes.layoutGridTitle')}
|
||||
>
|
||||
<LayoutGrid size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutMode('list')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'list'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('notes.layoutListTitle')}
|
||||
>
|
||||
<List size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutMode('table')}
|
||||
className={cn(
|
||||
'p-1.5 rounded-full transition-all',
|
||||
layoutMode === 'table'
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
title={t('notes.layoutTableTitle')}
|
||||
>
|
||||
<Table size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchParams.get('notebook') && (
|
||||
<button
|
||||
onClick={() => setSummaryDialogOpen(true)}
|
||||
@@ -733,46 +986,36 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
|
||||
) : (
|
||||
<div className="max-w-3xl space-y-16">
|
||||
{sortedPinnedNotes.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-2">
|
||||
{t('notes.pinned')}
|
||||
</h2>
|
||||
<NotesEditorialView
|
||||
notes={sortedPinnedNotes}
|
||||
onOpen={(note: Note, readOnly?: boolean) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||||
notebookName={currentNotebook?.name}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedNotes.filter((note) => !note.isPinned).length > 0 && (
|
||||
<NotesEditorialView
|
||||
notes={sortedNotes.filter((note) => !note.isPinned)}
|
||||
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||||
notebookName={currentNotebook?.name}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{notes.length === 0 && (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<p className="font-memento-serif text-xl italic text-muted-foreground">
|
||||
{t('notes.emptyState')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAddNote}
|
||||
disabled={isCreating}
|
||||
className="px-6 py-2 border border-foreground text-[13px] uppercase tracking-[0.2em] hover:bg-foreground hover:text-background transition-all"
|
||||
>
|
||||
{t('notes.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : notes.length === 0 ? (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<p className="font-memento-serif text-xl italic text-muted-foreground">
|
||||
{t('notes.emptyState')}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAddNote}
|
||||
disabled={isCreating}
|
||||
className="px-6 py-2 border border-foreground text-[13px] uppercase tracking-[0.2em] hover:bg-foreground hover:text-background transition-all"
|
||||
>
|
||||
{t('notes.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<NotesListViews
|
||||
notes={sortedNotes}
|
||||
pinnedNotes={sortedPinnedNotes}
|
||||
viewType={viewType}
|
||||
layoutMode={layoutMode}
|
||||
onOpen={(note, readOnly) => handleOpenNoteFresh(note.id, readOnly ?? false)}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
notebookName={currentNotebook?.name}
|
||||
onTogglePin={handleTogglePin}
|
||||
onDeleteNote={handleDeleteNoteFromList}
|
||||
onArchiveNote={handleArchiveNoteFromList}
|
||||
onMoveToNotebook={handleMoveNoteToNotebook}
|
||||
onNotePatch={handleNoteContentPatch}
|
||||
onNoteIllustrationGenerated={handleNoteIllustrationGenerated}
|
||||
onGridReorder={handleGridReorder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -784,8 +1027,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemoryEchoNotification onOpenNote={(noteId) => handleOpenNoteFresh(noteId)} />
|
||||
|
||||
{notebookSuggestion && (
|
||||
<NotebookSuggestionToast
|
||||
noteId={notebookSuggestion.noteId}
|
||||
@@ -798,7 +1039,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<BatchOrganizationDialog
|
||||
open={batchOrganizationOpen}
|
||||
onOpenChange={setBatchOrganizationOpen}
|
||||
onNotesMoved={() => router.refresh()}
|
||||
onNotesMoved={() => fetchNotesForCurrentView({ silent: true })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -810,7 +1051,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
if (!open) dismissLabelSuggestion()
|
||||
}}
|
||||
notebookId={suggestNotebookId}
|
||||
onLabelsCreated={() => router.refresh()}
|
||||
onLabelsCreated={() => fetchNotesForCurrentView({ silent: true })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -846,8 +1087,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
notebookName={currentNotebook.name}
|
||||
onDone={() => {
|
||||
refreshNotebooks()
|
||||
triggerRefresh()
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
152
memento-note/components/legal/ai-consent-modal.tsx
Normal file
152
memento-note/components/legal/ai-consent-modal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Sparkles, X, ShieldAlert, Check } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AiConsentModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (remember: boolean) => void
|
||||
}
|
||||
|
||||
export function AiConsentModal({ open, onClose, onConfirm }: AiConsentModalProps) {
|
||||
const { t } = useLanguage()
|
||||
const [remember, setRemember] = useState(true)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
|
||||
{/* Glassmorphism Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', duration: 0.4 }}
|
||||
className={cn(
|
||||
"relative w-full max-w-lg overflow-hidden border border-[var(--border)]",
|
||||
"bg-[var(--memento-paper)] text-[var(--ink)] shadow-2xl rounded-lg p-6",
|
||||
"flex flex-col gap-5"
|
||||
)}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-1 rounded-md text-[var(--ink)]/60 hover:text-[var(--ink)] hover:bg-[var(--concrete)] transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-[var(--accent-tint)] text-[var(--accent-color)] rounded-lg">
|
||||
<BrainIcon className="w-6 h-6 animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pr-6">
|
||||
<h3 className="text-lg font-semibold tracking-tight">
|
||||
{t('consent.ai.modalTitle') || 'Consentement requis pour le traitement par IA'}
|
||||
</h3>
|
||||
<span className="text-xs uppercase tracking-wider text-[var(--ink)]/50 font-medium">
|
||||
{t('consent.ai.complianceBadge') || 'Conformité RGPD'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="text-sm leading-relaxed text-[var(--ink)]/80 flex flex-col gap-3">
|
||||
<p>
|
||||
{t('consent.ai.modalDescription') ||
|
||||
"Pour analyser vos notes, PDFs ou sessions de remue-méninges, Memento transmet de manière sécurisée ces données à des API d'IA tierces (OpenAI, Gemini, DeepSeek). Nous appliquons une politique de rétention de données nulle. En acceptant, vous autorisez ce traitement."}
|
||||
</p>
|
||||
<div className="p-3 bg-[var(--concrete)] border border-[var(--border)] rounded-md text-xs flex gap-3 items-start">
|
||||
<ShieldAlert className="w-4 h-4 text-[var(--accent-color)] shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold">{t('consent.ai.zeroRetentionTitle') || 'Zéro Rétention de Données'}</span>
|
||||
<span>
|
||||
{t('consent.ai.zeroRetentionDesc') ||
|
||||
'Toutes les requêtes sortantes incluent des indicateurs de non-apprentissage pour protéger votre propriété intellectuelle.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkbox */}
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none py-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border border-[var(--border)] transition-colors flex items-center justify-center",
|
||||
remember ? "bg-[var(--accent-color)] border-[var(--accent-color)]" : "bg-transparent"
|
||||
)}
|
||||
>
|
||||
{remember && <Check className="w-3.5 h-3.5 text-white stroke-[3px]" />}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-[var(--ink)]/80">
|
||||
{t('consent.ai.rememberMe') || 'Se souvenir de mon choix (ne plus demander)'}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 mt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-semibold rounded-md border border-[var(--border)]",
|
||||
"hover:bg-[var(--concrete)] transition-colors bg-transparent text-[var(--ink)]"
|
||||
)}
|
||||
>
|
||||
{t('consent.ai.rejectButton') || 'Refuser'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(remember)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-xs font-semibold rounded-md text-white shadow-sm transition-opacity hover:opacity-90",
|
||||
"bg-[var(--accent-color)] flex items-center gap-2"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{t('consent.ai.acceptButton') || 'Autoriser et continuer'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function BrainIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.44 2.5 2.5 0 0 1 0-3.12 3 3 0 0 1 0-3.88 2.5 2.5 0 0 1 0-3.12A2.5 2.5 0 0 1 9.5 2Z" />
|
||||
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.44 2.5 2.5 0 0 0 0-3.12 3 3 0 0 0 0-3.88 2.5 2.5 0 0 0 0-3.12A2.5 2.5 0 0 0 14.5 2Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
173
memento-note/components/legal/ai-consent-provider.tsx
Normal file
173
memento-note/components/legal/ai-consent-provider.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { getLocalStorageAiConsent, setLocalStorageAiConsent, removeLocalStorageAiConsent } from '@/lib/consent/ai-consent-client'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { AiConsentModal } from './ai-consent-modal'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AiConsentContextType {
|
||||
hasAiConsent: boolean
|
||||
requestAiConsent: () => Promise<boolean>
|
||||
revokeConsent: () => Promise<void>
|
||||
}
|
||||
|
||||
const AiConsentContext = createContext<AiConsentContextType | undefined>(undefined)
|
||||
|
||||
interface AiConsentProviderProps {
|
||||
children: React.ReactNode
|
||||
initialPersistentConsent?: boolean
|
||||
}
|
||||
|
||||
export function AiConsentProvider({ children, initialPersistentConsent = false }: AiConsentProviderProps) {
|
||||
const { data: session, update: updateSession } = useSession()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const [persistentConsent, setPersistentConsent] = useState(false)
|
||||
const [sessionConsent, setSessionConsent] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
|
||||
const pendingResolveRef = useRef<((value: boolean) => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const local = getLocalStorageAiConsent()
|
||||
if (local || initialPersistentConsent) {
|
||||
setPersistentConsent(true)
|
||||
if (initialPersistentConsent && !local) {
|
||||
setLocalStorageAiConsent(true)
|
||||
}
|
||||
}
|
||||
setHydrated(true)
|
||||
}, [initialPersistentConsent])
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.aiSessionConsent === true) {
|
||||
setSessionConsent(true)
|
||||
} else if (session?.aiSessionConsent === false) {
|
||||
setSessionConsent(false)
|
||||
}
|
||||
}, [session?.aiSessionConsent])
|
||||
|
||||
const hasAiConsent =
|
||||
hydrated &&
|
||||
(persistentConsent || sessionConsent || session?.aiSessionConsent === true)
|
||||
|
||||
const requestAiConsent = useCallback((): Promise<boolean> => {
|
||||
if (hasAiConsent) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
setModalOpen(true)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
pendingResolveRef.current = resolve
|
||||
})
|
||||
}, [hasAiConsent])
|
||||
|
||||
const handleConfirm = async (remember: boolean) => {
|
||||
setModalOpen(false)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/ai-consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ consent: true, remember }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
toast.error(t('consent.ai.auditFailed') || 'Impossible d’enregistrer votre consentement. Réessayez.')
|
||||
pendingResolveRef.current?.(false)
|
||||
pendingResolveRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (remember) {
|
||||
setLocalStorageAiConsent(true)
|
||||
setPersistentConsent(true)
|
||||
try {
|
||||
await updateAISettings({ aiProcessingConsent: true })
|
||||
} catch (e) {
|
||||
console.error('[AiConsentProvider] Failed to sync consent to DB:', e)
|
||||
}
|
||||
} else {
|
||||
await updateSession({ aiSessionConsent: true })
|
||||
setSessionConsent(true)
|
||||
}
|
||||
|
||||
pendingResolveRef.current?.(true)
|
||||
pendingResolveRef.current = null
|
||||
} catch (e) {
|
||||
console.error('[AiConsentProvider] Failed to log consent audit:', e)
|
||||
toast.error(t('consent.ai.auditFailed') || 'Impossible d’enregistrer votre consentement. Réessayez.')
|
||||
pendingResolveRef.current?.(false)
|
||||
pendingResolveRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setModalOpen(false)
|
||||
toast.warning(t('consent.ai.aborted') || 'Traitement IA annulé (consentement refusé).')
|
||||
|
||||
pendingResolveRef.current?.(false)
|
||||
pendingResolveRef.current = null
|
||||
}
|
||||
|
||||
const revokeConsent = async () => {
|
||||
removeLocalStorageAiConsent()
|
||||
setPersistentConsent(false)
|
||||
setSessionConsent(false)
|
||||
|
||||
try {
|
||||
await fetch('/api/user/ai-consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ consent: false, remember: true }),
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[AiConsentProvider] Failed to log revocation:', e)
|
||||
}
|
||||
|
||||
try {
|
||||
await updateSession({ aiSessionConsent: false })
|
||||
} catch (e) {
|
||||
console.error('[AiConsentProvider] Failed to clear session consent:', e)
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
try {
|
||||
await updateAISettings({ aiProcessingConsent: false })
|
||||
} catch (e) {
|
||||
console.error('[AiConsentProvider] Failed to sync revocation to DB:', e)
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(t('consent.ai.revokedToast') || 'Consentement IA révoqué avec succès.')
|
||||
}
|
||||
|
||||
return (
|
||||
<AiConsentContext.Provider
|
||||
value={{
|
||||
hasAiConsent,
|
||||
requestAiConsent,
|
||||
revokeConsent,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<AiConsentModal
|
||||
open={modalOpen}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</AiConsentContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAiConsent() {
|
||||
const context = useContext(AiConsentContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAiConsent must be used within an AiConsentProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
178
memento-note/components/linked-note-preview-dialog.tsx
Normal file
178
memento-note/components/linked-note-preview-dialog.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ExternalLink, Link2, Columns2, GitMerge, Loader2, X } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LinkedNotePreviewDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
noteId: string
|
||||
initialTitle?: string | null
|
||||
initialExcerpt?: string
|
||||
onInsertCitation?: () => void
|
||||
onCompare?: () => void
|
||||
onMerge?: () => void
|
||||
citationLoading?: boolean
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function openNoteInNewTab(noteId: string) {
|
||||
window.open(`/home?openNote=${encodeURIComponent(noteId)}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const linkActionClass =
|
||||
'inline-flex items-center gap-1 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors hover:underline'
|
||||
|
||||
export function LinkedNotePreviewDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
noteId,
|
||||
initialTitle,
|
||||
initialExcerpt,
|
||||
onInsertCitation,
|
||||
onCompare,
|
||||
onMerge,
|
||||
citationLoading = false,
|
||||
}: LinkedNotePreviewDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const [title, setTitle] = useState(initialTitle ?? '')
|
||||
const [content, setContent] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !noteId) return
|
||||
let cancelled = false
|
||||
setIsLoading(true)
|
||||
setLoadError(false)
|
||||
setTitle(initialTitle ?? '')
|
||||
setContent('')
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}`)
|
||||
if (!res.ok) throw new Error('fetch failed')
|
||||
const data = await res.json()
|
||||
if (cancelled) return
|
||||
if (data.success && data.data) {
|
||||
setTitle(data.data.title || t('memoryEcho.comparison.untitled'))
|
||||
setContent(data.data.content || '')
|
||||
} else {
|
||||
setLoadError(true)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setLoadError(true)
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isOpen, noteId, initialTitle, t])
|
||||
|
||||
const displayTitle = title || initialTitle || t('memoryEcho.comparison.untitled')
|
||||
const plainBody = content ? stripHtml(content) : (initialExcerpt ?? '')
|
||||
const isHtml = content.includes('<')
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={open => { if (!open) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="max-w-lg max-h-[72vh] overflow-hidden flex flex-col p-0 gap-0">
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-border/50">
|
||||
<DialogTitle className="text-sm font-semibold truncate pr-2 leading-snug">
|
||||
{displayTitle}
|
||||
</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-0.5 rounded text-muted-foreground hover:text-foreground shrink-0"
|
||||
aria-label={t('memoryEcho.editorSection.close')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 min-h-0">
|
||||
{isLoading && !plainBody && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('memoryEcho.editorSection.loading')}
|
||||
</div>
|
||||
)}
|
||||
{loadError && !plainBody && (
|
||||
<p className="text-xs text-muted-foreground">{t('memoryEcho.preview.loadError')}</p>
|
||||
)}
|
||||
{content && isHtml && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-sm dark:prose-invert max-w-none',
|
||||
'prose-p:text-sm prose-p:leading-relaxed prose-p:my-2',
|
||||
'prose-headings:text-sm prose-headings:font-semibold prose-headings:mt-3 prose-headings:mb-1',
|
||||
'prose-li:text-sm prose-table:text-xs'
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
)}
|
||||
{!isHtml && plainBody && (
|
||||
<div className="text-sm leading-relaxed text-foreground/90 whitespace-pre-wrap font-serif">
|
||||
{plainBody}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2.5 border-t border-border/50 flex items-center justify-between gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openNoteInNewTab(noteId)}
|
||||
className={linkActionClass}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t('memoryEcho.editorSection.openInEditor')}
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{onCompare && (
|
||||
<button
|
||||
type="button"
|
||||
className={linkActionClass}
|
||||
onClick={() => { onCompare(); onClose() }}
|
||||
>
|
||||
<Columns2 className="h-3 w-3" />
|
||||
{t('memoryEcho.editorSection.compare')}
|
||||
</button>
|
||||
)}
|
||||
{onInsertCitation && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={citationLoading}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-bold text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
|
||||
onClick={() => { onInsertCitation(); onClose() }}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
{citationLoading
|
||||
? t('memoryEcho.editorSection.embedding')
|
||||
: t('memoryEcho.editorSection.embedPassage')}
|
||||
</button>
|
||||
)}
|
||||
{onMerge && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[11px] font-semibold text-purple-600 dark:text-purple-400 hover:underline"
|
||||
onClick={() => { onMerge(); onClose() }}
|
||||
>
|
||||
<GitMerge className="h-3 w-3" />
|
||||
{t('memoryEcho.editorSection.merge')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Lightbulb, ThumbsUp, ThumbsDown, X, Sparkles, ArrowRight, GitMerge } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { ComparisonModal } from './comparison-modal'
|
||||
import { FusionModal } from './fusion-modal'
|
||||
import { createNote, updateNote } from '@/app/actions/notes'
|
||||
import { Note } from '@/lib/types'
|
||||
|
||||
interface MemoryEchoInsight {
|
||||
id: string
|
||||
note1Id: string
|
||||
note2Id: string
|
||||
note1: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
note2: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
similarityScore: number
|
||||
insight: string
|
||||
insightDate: Date
|
||||
viewed: boolean
|
||||
feedback: string | null
|
||||
}
|
||||
|
||||
interface MemoryEchoNotificationProps {
|
||||
onOpenNote?: (noteId: string) => void
|
||||
}
|
||||
|
||||
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
|
||||
const { t } = useLanguage()
|
||||
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const [showComparison, setShowComparison] = useState(false)
|
||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||
const [demoMode, setDemoMode] = useState(false)
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const dismissedPermanently = useRef(false)
|
||||
|
||||
// Fetch insight on mount
|
||||
useEffect(() => {
|
||||
fetchInsight()
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchInsight = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/echo')
|
||||
const data = await res.json()
|
||||
|
||||
if (data.insight) {
|
||||
setInsight(data.insight)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to fetch insight:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (dismissedPermanently.current) return
|
||||
}, [isDismissed, demoMode])
|
||||
|
||||
const handleView = async () => {
|
||||
if (!insight) return
|
||||
|
||||
try {
|
||||
await fetch('/api/ai/echo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'view', insightId: insight.id })
|
||||
})
|
||||
|
||||
toast.success(t('toast.openingConnection'))
|
||||
setShowComparison(true)
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to view connection:', error)
|
||||
toast.error(t('toast.openConnectionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFeedback = async (feedback: 'thumbs_up' | 'thumbs_down') => {
|
||||
if (!insight) return
|
||||
|
||||
try {
|
||||
await fetch('/api/ai/echo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'feedback', insightId: insight.id, feedback })
|
||||
})
|
||||
|
||||
if (feedback === 'thumbs_up') {
|
||||
toast.success(t('toast.thanksFeedback'))
|
||||
} else {
|
||||
toast.success(t('toast.thanksFeedbackImproving'))
|
||||
}
|
||||
|
||||
setIsDismissed(true)
|
||||
// Stop polling after explicit feedback
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to submit feedback:', error)
|
||||
toast.error(t('toast.feedbackFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMergeNotes = async (noteIds: string[]) => {
|
||||
if (!insight) return
|
||||
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 }
|
||||
}))
|
||||
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true)
|
||||
dismissedPermanently.current = true
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !insight) {
|
||||
return null
|
||||
}
|
||||
|
||||
const note1Title = insight.note1.title || t('notification.untitled')
|
||||
const note2Title = insight.note2.title || t('notification.untitled')
|
||||
const similarityPercentage = Math.round(insight.similarityScore * 100)
|
||||
|
||||
const comparisonNotes: Array<Partial<Note>> = [
|
||||
{ id: insight.note1.id, title: insight.note1.title, content: insight.note1.content },
|
||||
{ id: insight.note2.id, title: insight.note2.title, content: insight.note2.content }
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Comparison Modal */}
|
||||
<ComparisonModal
|
||||
isOpen={showComparison}
|
||||
onClose={() => {
|
||||
setShowComparison(false)
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
notes={comparisonNotes}
|
||||
similarity={insight.similarityScore}
|
||||
onOpenNote={(noteId) => {
|
||||
setShowComparison(false)
|
||||
setIsDismissed(true)
|
||||
onOpenNote?.(noteId)
|
||||
}}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
/>
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes.length > 0 && (
|
||||
<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([])
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notification Card — hidden when dismissed or modal open */}
|
||||
{!isDismissed && (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-md w-full animate-in slide-in-from-bottom-4 fade-in duration-500">
|
||||
<Card className="border-amber-200 dark:border-amber-900 shadow-lg bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/20 dark:to-background">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||
<Lightbulb className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{t('memoryEcho.title')}
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">
|
||||
{t('memoryEcho.description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -mr-2 -mt-2"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* AI-generated insight */}
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-lg p-3 border border-amber-100 dark:border-amber-900/30">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{insight.insight}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connected notes */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="border-primary/20 text-primary dark:border-primary/30 dark:text-primary-foreground">
|
||||
{note1Title}
|
||||
</Badge>
|
||||
<ArrowRight className="h-3 w-3 text-gray-400" />
|
||||
<Badge variant="outline" className="border-purple-200 text-purple-700 dark:border-purple-900 dark:text-purple-300">
|
||||
{note2Title}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{t('memoryEcho.match', { percentage: similarityPercentage })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onClick={handleView}
|
||||
>
|
||||
{t('memoryEcho.viewConnection')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50 dark:border-purple-800 dark:text-purple-400 dark:hover:bg-purple-950/20"
|
||||
onClick={() => handleMergeNotes([insight.note1.id, insight.note2.id])}
|
||||
>
|
||||
<GitMerge className="h-4 w-4 mr-1" />
|
||||
{t('memoryEcho.editorSection.merge')}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1 border-l pl-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
|
||||
onClick={() => handleFeedback('thumbs_up')}
|
||||
title={t('memoryEcho.helpful')}
|
||||
>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={() => handleFeedback('thumbs_down')}
|
||||
title={t('memoryEcho.notHelpful')}
|
||||
>
|
||||
<ThumbsDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss link */}
|
||||
<button
|
||||
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
{t('memoryEcho.dismiss')}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
546
memento-note/components/memory-echo-section.tsx
Normal file
546
memento-note/components/memory-echo-section.tsx
Normal file
@@ -0,0 +1,546 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { ChevronDown, ChevronUp, Sparkles, Link2, X, Loader2, HelpCircle } from 'lucide-react'
|
||||
import { LinkedNotePreviewDialog } from '@/components/linked-note-preview-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import type { BlockSuggestion } from '@/components/block-picker'
|
||||
import { toast } from 'sonner'
|
||||
import { useNoteEditorContext } from '@/components/note-editor/note-editor-context'
|
||||
|
||||
interface ConnectionData {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
createdAt: Date
|
||||
similarity: number
|
||||
daysApart: number
|
||||
}
|
||||
|
||||
interface LiveBlockRefHost {
|
||||
targetNoteId: string
|
||||
targetNoteTitle: string
|
||||
notebookName: string
|
||||
blockIds: string[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface MemoryEchoSectionProps {
|
||||
noteId: string
|
||||
onCompareNotes?: (noteIds: string[], meta?: { similarity?: number }) => void
|
||||
onMergeNotes?: (noteIds: string[]) => void
|
||||
}
|
||||
|
||||
interface PreviewTarget {
|
||||
noteId: string
|
||||
title: string | null
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function excerpt(text: string, max = 150): string {
|
||||
const plain = stripHtml(text)
|
||||
if (plain.length <= max) return plain
|
||||
return `${plain.slice(0, max).trim()}…`
|
||||
}
|
||||
|
||||
async function resolveBlockForEmbed(sourceNoteId: string, hint: string): Promise<{ block: BlockSuggestion; mode: 'live' | 'citation' } | null> {
|
||||
const params = new URLSearchParams({ noteId: sourceNoteId, hint })
|
||||
const res = await fetch(`/api/blocks/resolve?${params}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
if (!data.block) return null
|
||||
return { block: data.block as BlockSuggestion, mode: data.mode === 'live' ? 'live' : 'citation' }
|
||||
}
|
||||
|
||||
function dispatchLiveBlockInsert(block: BlockSuggestion) {
|
||||
window.dispatchEvent(new CustomEvent('memento-insert-live-block', { detail: { block } }))
|
||||
}
|
||||
|
||||
function dispatchCitationInsert(payload: { noteId: string; noteTitle: string; excerpt: string }) {
|
||||
window.dispatchEvent(new CustomEvent('memento-insert-citation', { detail: { ...payload, atEnd: true } }))
|
||||
}
|
||||
|
||||
function ActionWithHelp({
|
||||
label,
|
||||
help,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
help: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px] text-left">
|
||||
<p className="font-medium mb-0.5">{label}</p>
|
||||
<p className="text-background/80">{help}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemoryEchoSection({
|
||||
noteId,
|
||||
onCompareNotes,
|
||||
onMergeNotes,
|
||||
}: MemoryEchoSectionProps) {
|
||||
const { t } = useLanguage()
|
||||
const editorCtx = useNoteEditorContext()
|
||||
const [connections, setConnections] = useState<ConnectionData[]>([])
|
||||
const [retroRefs, setRetroRefs] = useState<LiveBlockRefHost[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [consentRequired, setConsentRequired] = useState(false)
|
||||
const [embeddingId, setEmbeddingId] = useState<string | null>(null)
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
const [previewTarget, setPreviewTarget] = useState<PreviewTarget | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setIsLoading(true)
|
||||
setConsentRequired(false)
|
||||
setConnections([])
|
||||
setRetroRefs([])
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [connRes, retroRes] = await Promise.all([
|
||||
fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=10`),
|
||||
fetch(`/api/notes/${noteId}/live-block-refs`),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
if (connRes.status === 403) {
|
||||
setConsentRequired(true)
|
||||
setConnections([])
|
||||
} else if (connRes.ok) {
|
||||
const data = await connRes.json()
|
||||
setConnections(data.connections || [])
|
||||
} else {
|
||||
setConnections([])
|
||||
}
|
||||
|
||||
if (retroRes.ok) {
|
||||
const data = await retroRes.json()
|
||||
setRetroRefs(data.refs || [])
|
||||
} else {
|
||||
setRetroRefs([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MemoryEchoSection] Failed to fetch:', error)
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load léger : ne bloque pas l'ouverture de la note
|
||||
const timer = window.setTimeout(() => { void load() }, 400)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [noteId])
|
||||
|
||||
// Scroll doux vers la section quand une forte connexion apparaît (une fois par note)
|
||||
useEffect(() => {
|
||||
if (isLoading || connections.length === 0) return
|
||||
const top = connections[0]
|
||||
if (!top || top.similarity < 0.75) return
|
||||
const key = `memory-echo-scroll-${noteId}`
|
||||
if (sessionStorage.getItem(key)) return
|
||||
sessionStorage.setItem(key, '1')
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById('memory-echo-section')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
})
|
||||
}, [isLoading, connections, noteId])
|
||||
|
||||
const handleEmbed = useCallback(async (conn: ConnectionData) => {
|
||||
setEmbeddingId(conn.noteId)
|
||||
try {
|
||||
const hint = excerpt(stripHtml(conn.content), 300)
|
||||
const resolved = await resolveBlockForEmbed(conn.noteId, hint)
|
||||
const noteTitle = conn.title || t('memoryEcho.comparison.untitled')
|
||||
|
||||
if (resolved?.mode === 'live' && resolved.block.blockId) {
|
||||
const block = resolved.block
|
||||
if (noteId) {
|
||||
try {
|
||||
await fetch('/api/blocks/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourceNoteId: block.noteId,
|
||||
blockId: block.blockId,
|
||||
targetNoteId: noteId,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// non bloquant
|
||||
}
|
||||
}
|
||||
const insertedLive = editorCtx.richTextEditorRef.current?.insertLiveBlock(block, { atEnd: true })
|
||||
if (!insertedLive) {
|
||||
dispatchLiveBlockInsert(block)
|
||||
}
|
||||
if (insertedLive || editorCtx.richTextEditorRef.current?.getEditor()) {
|
||||
toast.success(t('memoryEcho.editorSection.embedSuccess'))
|
||||
} else {
|
||||
toast.error(t('memoryEcho.editorSection.embedFailed'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const citationText = stripHtml(
|
||||
resolved?.block.content || conn.content || hint
|
||||
).slice(0, 1200)
|
||||
|
||||
if (!citationText.trim()) {
|
||||
toast.error(t('memoryEcho.editorSection.embedFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = { noteId: conn.noteId, noteTitle, excerpt: citationText }
|
||||
|
||||
if (editorCtx.state.isMarkdown) {
|
||||
const quoted = citationText.split('\n').map(line => `> ${line}`).join('\n')
|
||||
const mdCitation = `\n\n${quoted}\n\n— [${noteTitle}](/home?openNote=${conn.noteId})\n`
|
||||
editorCtx.actions.setContent(editorCtx.state.content + mdCitation)
|
||||
toast.success(t('memoryEcho.editorSection.citationSuccess'))
|
||||
return
|
||||
}
|
||||
|
||||
const inserted = editorCtx.richTextEditorRef.current?.insertCitation(payload, { atEnd: true })
|
||||
if (inserted) {
|
||||
toast.success(t('memoryEcho.editorSection.citationSuccess'))
|
||||
return
|
||||
}
|
||||
|
||||
dispatchCitationInsert(payload)
|
||||
toast.success(t('memoryEcho.editorSection.citationSuccess'))
|
||||
} catch {
|
||||
toast.error(t('memoryEcho.editorSection.embedFailed'))
|
||||
} finally {
|
||||
setEmbeddingId(null)
|
||||
}
|
||||
}, [t, noteId, editorCtx])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
const hasConnections = connections.length > 0
|
||||
const hasRetro = retroRefs.length > 0
|
||||
|
||||
if (!isLoading && !hasConnections && !hasRetro && !consentRequired) {
|
||||
return null
|
||||
}
|
||||
|
||||
const topConnection = connections[0]
|
||||
const restConnections = connections.slice(1)
|
||||
|
||||
return (
|
||||
<div id="memory-echo-section" className="mt-10 space-y-6 scroll-mt-24">
|
||||
{isLoading && (
|
||||
<div
|
||||
className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-5 animate-pulse space-y-3"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-indigo-400" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-indigo-500/60">
|
||||
{t('memoryEcho.editorSection.badgeLabel')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-4 bg-indigo-500/10 rounded w-3/4" />
|
||||
<div className="h-16 bg-indigo-500/5 rounded border-l-2 border-indigo-500/20" />
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('memoryEcho.editorSection.loading')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && consentRequired && (
|
||||
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/[0.04] p-4 text-sm text-muted-foreground">
|
||||
{t('memoryEcho.editorSection.consentRequired')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && hasConnections && topConnection && (
|
||||
<div className="rounded-2xl border border-indigo-500/10 bg-gradient-to-br from-indigo-500/[0.03] to-transparent p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Sparkles className="h-4 w-4 text-indigo-500 animate-pulse shrink-0" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.25em] text-indigo-600/80 dark:text-indigo-400/80 truncate">
|
||||
{t('memoryEcho.editorSection.badgeLabel')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(v => !v)}
|
||||
className="p-0.5 rounded-md text-indigo-500/70 hover:text-indigo-600 hover:bg-indigo-500/10 transition-colors"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label={t('memoryEcho.editorSection.helpToggle')}
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[11px] font-mono font-semibold px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border border-indigo-500/15">
|
||||
{t('memoryEcho.editorSection.affinityBadge', {
|
||||
percentage: Math.round(topConnection.similarity * 100),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setIsVisible(false)}
|
||||
title={t('memoryEcho.editorSection.close')}
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground hidden sm:block">
|
||||
{t('memoryEcho.editorSection.intro')}
|
||||
</p>
|
||||
|
||||
{helpOpen && (
|
||||
<div className="rounded-xl border border-indigo-500/15 bg-indigo-500/[0.04] p-4 space-y-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">{t('memoryEcho.editorSection.helpTitle')}</p>
|
||||
<ul className="space-y-2.5 list-none pl-0">
|
||||
<li><strong className="text-foreground">{t('memoryEcho.editorSection.viewLinkedNote')}</strong> — {t('memoryEcho.editorSection.helpView')}</li>
|
||||
<li><strong className="text-foreground">{t('memoryEcho.editorSection.embedPassage')}</strong> — {t('memoryEcho.editorSection.helpCite')}</li>
|
||||
<li><strong className="text-foreground">{t('memoryEcho.editorSection.compare')}</strong> — {t('memoryEcho.editorSection.helpCompare')}</li>
|
||||
<li><strong className="text-foreground">{t('memoryEcho.editorSection.merge')}</strong> — {t('memoryEcho.editorSection.helpMerge')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<blockquote className="border-l-2 border-indigo-500/20 pl-3 font-serif italic text-sm leading-relaxed text-foreground/85">
|
||||
« {excerpt(topConnection.content)} »
|
||||
</blockquote>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1 border-t border-indigo-500/10 flex-wrap text-[11px]">
|
||||
<span className="text-muted-foreground min-w-0 truncate">
|
||||
{t('memoryEcho.editorSection.detectedIn', {
|
||||
title: topConnection.title || t('memoryEcho.comparison.untitled'),
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<ActionWithHelp
|
||||
label={t('memoryEcho.editorSection.viewLinkedNote')}
|
||||
help={t('memoryEcho.editorSection.helpView')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||
onClick={() => setPreviewTarget({
|
||||
noteId: topConnection.noteId,
|
||||
title: topConnection.title,
|
||||
excerpt: excerpt(topConnection.content, 500),
|
||||
})}
|
||||
>
|
||||
{t('memoryEcho.editorSection.view')}
|
||||
</button>
|
||||
</ActionWithHelp>
|
||||
<ActionWithHelp
|
||||
label={t('memoryEcho.editorSection.embedPassage')}
|
||||
help={t('memoryEcho.editorSection.helpCite')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={embeddingId === topConnection.noteId}
|
||||
className="inline-flex items-center gap-1 font-bold text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
|
||||
onClick={() => void handleEmbed(topConnection)}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
{embeddingId === topConnection.noteId
|
||||
? t('memoryEcho.editorSection.embedding')
|
||||
: t('memoryEcho.editorSection.embedPassage')}
|
||||
</button>
|
||||
</ActionWithHelp>
|
||||
{onCompareNotes && (
|
||||
<ActionWithHelp
|
||||
label={t('memoryEcho.editorSection.compare')}
|
||||
help={t('memoryEcho.editorSection.helpCompare')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||
onClick={() => onCompareNotes([noteId, topConnection.noteId], { similarity: topConnection.similarity })}
|
||||
>
|
||||
{t('memoryEcho.editorSection.compare')}
|
||||
</button>
|
||||
</ActionWithHelp>
|
||||
)}
|
||||
{onMergeNotes && (
|
||||
<ActionWithHelp
|
||||
label={t('memoryEcho.editorSection.merge')}
|
||||
help={t('memoryEcho.editorSection.helpMerge')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-purple-600 dark:text-purple-400 hover:underline transition-colors"
|
||||
onClick={() => onMergeNotes([noteId, topConnection.noteId])}
|
||||
>
|
||||
{t('memoryEcho.editorSection.merge')}
|
||||
</button>
|
||||
</ActionWithHelp>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{restConnections.length > 0 && (
|
||||
<div className="pt-2 border-t border-indigo-500/10">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
|
||||
onClick={() => setIsExpanded(v => !v)}
|
||||
>
|
||||
{isExpanded
|
||||
? t('memoryEcho.editorSection.hideAll', { count: connections.length })
|
||||
: t('memoryEcho.editorSection.showAll', { count: connections.length })}
|
||||
{isExpanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 space-y-2 max-h-[280px] overflow-y-auto">
|
||||
{restConnections.map(conn => (
|
||||
<div
|
||||
key={conn.noteId}
|
||||
className="rounded-xl border border-border/60 p-3 bg-background/60 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium flex-1">
|
||||
{conn.title || t('memoryEcho.comparison.untitled')}
|
||||
</h4>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-700 dark:text-indigo-300">
|
||||
{Math.round(conn.similarity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 font-serif italic">
|
||||
« {excerpt(conn.content, 120)} »
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-muted-foreground hover:text-foreground hover:underline"
|
||||
onClick={() => setPreviewTarget({
|
||||
noteId: conn.noteId,
|
||||
title: conn.title,
|
||||
excerpt: excerpt(conn.content, 500),
|
||||
})}
|
||||
>
|
||||
{t('memoryEcho.editorSection.view')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={embeddingId === conn.noteId}
|
||||
className="inline-flex items-center gap-1 font-bold text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
|
||||
onClick={() => void handleEmbed(conn)}
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
{t('memoryEcho.editorSection.embedPassage')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && hasRetro && (
|
||||
<div className="rounded-2xl border border-blue-500/10 bg-blue-500/[0.02] p-5 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-60" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.22em] text-blue-600/80 dark:text-blue-400/80">
|
||||
{t('memoryEcho.editorSection.retroTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('memoryEcho.editorSection.retroDescription', { count: retroRefs.length })}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{retroRefs.map(ref => (
|
||||
<button
|
||||
key={ref.targetNoteId}
|
||||
type="button"
|
||||
onClick={() => setPreviewTarget({
|
||||
noteId: ref.targetNoteId,
|
||||
title: ref.targetNoteTitle,
|
||||
excerpt: '',
|
||||
})}
|
||||
className={cn(
|
||||
'w-full text-left rounded-xl border border-border/50 p-3',
|
||||
'hover:bg-muted/40 transition-colors flex items-center gap-3'
|
||||
)}
|
||||
>
|
||||
<Link2 className="h-4 w-4 text-blue-500 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{ref.targetNoteTitle || t('memoryEcho.comparison.untitled')}
|
||||
</p>
|
||||
{ref.notebookName && (
|
||||
<p className="text-[11px] text-muted-foreground truncate">{ref.notebookName}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewTarget && (
|
||||
<LinkedNotePreviewDialog
|
||||
isOpen
|
||||
onClose={() => setPreviewTarget(null)}
|
||||
noteId={previewTarget.noteId}
|
||||
initialTitle={previewTarget.title}
|
||||
initialExcerpt={previewTarget.excerpt}
|
||||
citationLoading={embeddingId === previewTarget.noteId}
|
||||
onInsertCitation={() => {
|
||||
void handleEmbed({
|
||||
noteId: previewTarget.noteId,
|
||||
title: previewTarget.title,
|
||||
content: previewTarget.excerpt || connections.find(c => c.noteId === previewTarget.noteId)?.content || '',
|
||||
createdAt: new Date(),
|
||||
similarity: connections.find(c => c.noteId === previewTarget.noteId)?.similarity ?? 0,
|
||||
daysApart: 0,
|
||||
})
|
||||
}}
|
||||
onCompare={
|
||||
onCompareNotes
|
||||
? () => onCompareNotes([noteId, previewTarget.noteId], {
|
||||
similarity: connections.find(c => c.noteId === previewTarget.noteId)?.similarity,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onMerge={
|
||||
onMergeNotes
|
||||
? () => onMergeNotes([noteId, previewTarget.noteId])
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use MemoryEchoSection */
|
||||
export { MemoryEchoSection as EditorConnectionsSection }
|
||||
184
memento-note/components/move-to-notebook-picker.tsx
Normal file
184
memento-note/components/move-to-notebook-picker.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AnimatePresence } from 'motion/react'
|
||||
import {
|
||||
getNotebookPickerPosition,
|
||||
NotebookHierarchyPanel,
|
||||
type NotebookPickerItem,
|
||||
} from '@/components/notebook-hierarchy-panel'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
type MoveToNotebookPickerProps = {
|
||||
notebooks: NotebookPickerItem[]
|
||||
currentNotebookId?: string | null
|
||||
onSelect: (notebookId: string | null) => void
|
||||
children: React.ReactElement
|
||||
align?: 'start' | 'end'
|
||||
preferDropUp?: boolean
|
||||
}
|
||||
|
||||
export function MoveToNotebookPicker({
|
||||
notebooks,
|
||||
currentNotebookId = null,
|
||||
onSelect,
|
||||
children,
|
||||
align = 'end',
|
||||
preferDropUp = false,
|
||||
}: MoveToNotebookPickerProps) {
|
||||
const { t } = useLanguage()
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLElement>(null)
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties | undefined>()
|
||||
|
||||
const close = useCallback(() => setOpen(false), [])
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
setPanelStyle(
|
||||
getNotebookPickerPosition(triggerRef.current.getBoundingClientRect(), { align, preferDropUp }),
|
||||
)
|
||||
}, [align, preferDropUp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPanelStyle(undefined)
|
||||
return
|
||||
}
|
||||
updatePosition()
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
window.addEventListener('resize', updatePosition)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
}
|
||||
}, [open, updatePosition])
|
||||
|
||||
const handleSelect = (notebookId: string | null) => {
|
||||
onSelect(notebookId)
|
||||
close()
|
||||
}
|
||||
|
||||
const child = React.cloneElement(children, {
|
||||
ref: (node: HTMLElement | null) => {
|
||||
triggerRef.current = node
|
||||
const childRef = (children as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref
|
||||
if (typeof childRef === 'function') childRef(node)
|
||||
else if (childRef && typeof childRef === 'object') {
|
||||
;(childRef as React.MutableRefObject<HTMLElement | null>).current = node
|
||||
}
|
||||
},
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
children.props.onClick?.(e)
|
||||
setOpen((prev) => !prev)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{child}
|
||||
{typeof window !== 'undefined' &&
|
||||
createPortal(
|
||||
<AnimatePresence>
|
||||
{open && panelStyle && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9998]" onClick={close} aria-hidden />
|
||||
<NotebookHierarchyPanel
|
||||
key="move-notebook-picker"
|
||||
notebooks={notebooks}
|
||||
selectedId={currentNotebookId}
|
||||
onSelect={(id) => handleSelect(id)}
|
||||
onClose={close}
|
||||
showGeneralNotes
|
||||
generalNotesLabel={t('notebookSuggestion.generalNotes') || 'Notes générales'}
|
||||
onSelectGeneralNotes={() => handleSelect(null)}
|
||||
searchPlaceholder={t('notebookSuggestion.filterNotebooks') || 'Filtrer les carnets…'}
|
||||
closeLabel={t('general.close') || 'Fermer'}
|
||||
style={panelStyle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type MoveToNotebookPickerPortalProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
anchorRef: React.RefObject<HTMLElement | null>
|
||||
notebooks: NotebookPickerItem[]
|
||||
currentNotebookId?: string | null
|
||||
onSelect: (notebookId: string | null) => void
|
||||
align?: 'start' | 'end'
|
||||
preferDropUp?: boolean
|
||||
}
|
||||
|
||||
export function MoveToNotebookPickerPortal({
|
||||
open,
|
||||
onOpenChange,
|
||||
anchorRef,
|
||||
notebooks,
|
||||
currentNotebookId = null,
|
||||
onSelect,
|
||||
align = 'end',
|
||||
preferDropUp = false,
|
||||
}: MoveToNotebookPickerPortalProps) {
|
||||
const { t } = useLanguage()
|
||||
const [panelStyle, setPanelStyle] = useState<React.CSSProperties | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !anchorRef.current) {
|
||||
setPanelStyle(undefined)
|
||||
return
|
||||
}
|
||||
const update = () => {
|
||||
if (!anchorRef.current) return
|
||||
setPanelStyle(
|
||||
getNotebookPickerPosition(anchorRef.current.getBoundingClientRect(), { align, preferDropUp }),
|
||||
)
|
||||
}
|
||||
update()
|
||||
window.addEventListener('scroll', update, true)
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update, true)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [open, anchorRef, align, preferDropUp])
|
||||
|
||||
const handleSelect = (notebookId: string | null) => {
|
||||
onSelect(notebookId)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && panelStyle && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9998]" onClick={() => onOpenChange(false)} aria-hidden />
|
||||
<NotebookHierarchyPanel
|
||||
key="move-notebook-picker-portal"
|
||||
notebooks={notebooks}
|
||||
selectedId={currentNotebookId}
|
||||
onSelect={(id) => handleSelect(id)}
|
||||
onClose={() => onOpenChange(false)}
|
||||
showGeneralNotes
|
||||
generalNotesLabel={t('notebookSuggestion.generalNotes') || 'Notes générales'}
|
||||
onSelectGeneralNotes={() => handleSelect(null)}
|
||||
searchPlaceholder={t('notebookSuggestion.filterNotebooks') || 'Filtrer les carnets…'}
|
||||
closeLabel={t('general.close') || 'Fermer'}
|
||||
style={panelStyle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
244
memento-note/components/network-graph.tsx
Normal file
244
memento-note/components/network-graph.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
// Force to group nodes by cluster
|
||||
function forceCluster() {
|
||||
let nodes: any[] = []
|
||||
let clusters: Map<string | number, { x: number; y: number }> = new Map()
|
||||
|
||||
function force(alpha: number) {
|
||||
// Calculate cluster centers
|
||||
clusters.clear()
|
||||
for (const node of nodes) {
|
||||
const clusterId = node.clusterId
|
||||
if (!clusters.has(clusterId)) {
|
||||
clusters.set(clusterId, { x: 0, y: 0, count: 0 })
|
||||
}
|
||||
const center = clusters.get(clusterId)!
|
||||
center.x += node.x
|
||||
center.y += node.y
|
||||
center.count = (center.count || 0) + 1
|
||||
}
|
||||
|
||||
// Average positions
|
||||
for (const [clusterId, center] of clusters.entries()) {
|
||||
center.x /= center.count || 1
|
||||
center.y /= center.count || 1
|
||||
}
|
||||
|
||||
// Move nodes toward their cluster center
|
||||
for (const node of nodes) {
|
||||
const clusterCenter = clusters.get(node.clusterId)
|
||||
if (clusterCenter && clusterCenter.count > 1) {
|
||||
const targetX = clusterCenter.x
|
||||
const targetY = clusterCenter.y
|
||||
node.vx += (targetX - node.x) * alpha * 0.3
|
||||
node.vy += (targetY - node.y) * alpha * 0.3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
force.initialize = function(newNodes: any[]) {
|
||||
nodes = newNodes
|
||||
return force
|
||||
}
|
||||
|
||||
return force
|
||||
}
|
||||
|
||||
interface Note {
|
||||
id: string
|
||||
title: string | null
|
||||
clusterId?: string | number
|
||||
}
|
||||
|
||||
interface NoteCluster {
|
||||
id: string | number
|
||||
name: string
|
||||
noteIds: string[]
|
||||
color: string
|
||||
}
|
||||
|
||||
interface BridgeNote {
|
||||
noteId: string
|
||||
bridgeScore: number
|
||||
clustersConnected?: (string | number)[]
|
||||
connectedClusterIds?: (string | number)[]
|
||||
}
|
||||
|
||||
interface NetworkGraphProps {
|
||||
notes: Note[]
|
||||
clusters: NoteCluster[]
|
||||
bridgeNotes: BridgeNote[]
|
||||
onNoteSelect: (id: string) => void
|
||||
}
|
||||
|
||||
export function NetworkGraph({
|
||||
notes,
|
||||
clusters,
|
||||
bridgeNotes,
|
||||
onNoteSelect
|
||||
}: NetworkGraphProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current) return
|
||||
|
||||
const width = containerRef.current.clientWidth
|
||||
const height = containerRef.current.clientHeight
|
||||
|
||||
const svg = d3.select(svgRef.current)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform)
|
||||
})
|
||||
|
||||
svg.call(zoom as any)
|
||||
|
||||
// Filter notes with cluster assignments (properly check for undefined/null)
|
||||
const visibleNotes = notes.filter(n => n.clusterId !== undefined && n.clusterId !== null)
|
||||
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string
|
||||
title: string | null
|
||||
clusterId: string | number
|
||||
color: string
|
||||
isBridge: boolean
|
||||
radius: number
|
||||
}
|
||||
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
source: string
|
||||
target: string
|
||||
strength: number
|
||||
}
|
||||
|
||||
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId))
|
||||
|
||||
const nodes: D3Node[] = visibleNotes.map(n => {
|
||||
const cluster = clusters.find(c => c.id === String(n.clusterId))
|
||||
const isBridge = bridgeSet.has(n.id)
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
clusterId: n.clusterId!,
|
||||
color: cluster?.color || '#cbd5e1',
|
||||
isBridge,
|
||||
radius: isBridge ? 12 : 8
|
||||
}
|
||||
})
|
||||
|
||||
const links: D3Link[] = []
|
||||
// Connect notes within the same cluster
|
||||
for (let i = 0; i < visibleNotes.length; i++) {
|
||||
for (let j = i + 1; j < visibleNotes.length; j++) {
|
||||
const ni = visibleNotes[i]
|
||||
const nj = visibleNotes[j]
|
||||
|
||||
if (ni.clusterId === nj.clusterId) {
|
||||
links.push({ source: ni.id, target: nj.id, strength: 0.5 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const simulation = d3.forceSimulation<D3Node>(nodes)
|
||||
.force('link', d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(50))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide<D3Node>().radius(d => d.radius + 15))
|
||||
.force('cluster', forceCluster())
|
||||
|
||||
// Links
|
||||
const link = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('stroke', '#e2e8f0')
|
||||
.attr('stroke-opacity', 0.6)
|
||||
.attr('stroke-width', 1)
|
||||
|
||||
// Nodes
|
||||
const node = g.append('g')
|
||||
.selectAll('.node')
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', 'node cursor-pointer')
|
||||
.on('click', (event, d) => onNoteSelect(d.id))
|
||||
.call(d3.drag<SVGGElement, D3Node>()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended) as any)
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', d => d.radius)
|
||||
.attr('fill', d => d.color)
|
||||
.attr('stroke', d => d.isBridge ? '#D4AF37' : '#fff')
|
||||
.attr('stroke-width', d => d.isBridge ? 3 : 2)
|
||||
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))' : 'none')
|
||||
|
||||
node.append('text')
|
||||
.attr('dy', d => d.radius + 14)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('class', 'text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none')
|
||||
.text(d => {
|
||||
const title = d.title || 'Untitled'
|
||||
return title.length > 20 ? title.substring(0, 20) + '...' : title
|
||||
})
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => (d.source as any).x)
|
||||
.attr('y1', d => (d.source as any).y)
|
||||
.attr('x2', d => (d.target as any).x)
|
||||
.attr('y2', d => (d.target as any).y)
|
||||
|
||||
node
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
|
||||
function dragstarted(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
}
|
||||
|
||||
function dragged(event: any, d: D3Node) {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
}
|
||||
|
||||
function dragended(event: any, d: D3Node) {
|
||||
if (!event.active) simulation.alphaTarget(0)
|
||||
d.fx = null
|
||||
d.fy = null
|
||||
}
|
||||
|
||||
return () => {
|
||||
simulation.stop()
|
||||
}
|
||||
}, [notes, clusters, bridgeNotes, onNoteSelect])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
|
||||
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-3 max-w-[300px]">
|
||||
{clusters.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-white/80 dark:bg-white/5 backdrop-blur-sm border border-border rounded-full shadow-sm">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({
|
||||
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
|
||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -178,7 +178,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
}: NoteCardProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { refreshNotes } = useRefresh()
|
||||
const { data: session } = useSession()
|
||||
const { t, language } = useLanguage()
|
||||
const { notebooks, moveNoteToNotebookOptimistic, refreshLabels } = useNotebooks()
|
||||
@@ -203,9 +202,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateNote(noteId, { reminder })
|
||||
await updateNote(noteId, { reminder }, { skipRevalidation: true })
|
||||
setReminderDate(reminder)
|
||||
refreshNotes(note?.notebookId)
|
||||
emitNoteChange({ type: 'updated', note: { ...note, reminder: reminder?.toISOString() ?? null } })
|
||||
if (reminder) {
|
||||
toast.success(t('notes.reminderSet', { datetime: reminder.toLocaleString() }))
|
||||
} else {
|
||||
@@ -304,9 +303,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
setIsDeleting(true)
|
||||
setIsHidden(true) // masquage immédiat
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
await refreshLabels()
|
||||
refreshNotes(note?.notebookId) // met à jour la liste et le compteur du carnet
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsHidden(false)
|
||||
@@ -319,7 +318,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
setIsHidden(true)
|
||||
try {
|
||||
await restoreNote(note.id)
|
||||
refreshNotes(note?.notebookId)
|
||||
emitNoteChange({ type: 'updated', note: { ...note, trashedAt: null } })
|
||||
toast.success(t('trash.noteRestored'))
|
||||
} catch (error) {
|
||||
console.error('Failed to restore note:', error)
|
||||
@@ -333,7 +332,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
setIsHidden(true)
|
||||
try {
|
||||
await permanentDeleteNote(note.id)
|
||||
refreshNotes(note?.notebookId)
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('trash.notePermanentlyDeleted'))
|
||||
} catch (error) {
|
||||
console.error('Failed to permanently delete note:', error)
|
||||
@@ -345,8 +344,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
const handleTogglePin = async () => {
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ isPinned: !note.isPinned })
|
||||
await togglePin(note.id, !note.isPinned)
|
||||
refreshNotes(note?.notebookId)
|
||||
await togglePin(note.id, !note.isPinned, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, isPinned: !note.isPinned } })
|
||||
|
||||
if (!note.isPinned) {
|
||||
toast.success(t('notes.pinned'))
|
||||
@@ -359,8 +358,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
const handleToggleArchive = async () => {
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ isArchived: !note.isArchived })
|
||||
await toggleArchive(note.id, !note.isArchived)
|
||||
refreshNotes(note?.notebookId)
|
||||
await toggleArchive(note.id, !note.isArchived, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, isArchived: !note.isArchived } })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -368,7 +367,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
setLocalColor(color) // instant visual update, survives transition
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ color })
|
||||
await updateNote(note.id, { color }, { skipRevalidation: false })
|
||||
await updateNote(note.id, { color }, { skipRevalidation: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,7 +402,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
setLocalCheckItems(updatedItems) // instant visual update, survives transition
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ checkItems: updatedItems })
|
||||
await updateNote(note.id, { checkItems: updatedItems })
|
||||
await updateNote(note.id, { checkItems: updatedItems }, { skipRevalidation: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -821,12 +820,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
isOpen={!!comparisonNotes}
|
||||
onClose={() => setComparisonNotes(null)}
|
||||
notes={comparisonNotesData}
|
||||
onOpenNote={(noteId) => {
|
||||
const foundNote = comparisonNotesData.find(n => n.id === noteId)
|
||||
if (foundNote) {
|
||||
onEdit?.(foundNote, false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -839,7 +832,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
onClose={() => setFusionNotes([])}
|
||||
notes={fusionNotes}
|
||||
onConfirmFusion={async ({ title, content }, options) => {
|
||||
await createNote({
|
||||
const created = await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
@@ -854,12 +847,12 @@ export const NoteCard = memo(function NoteCard({
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true })
|
||||
if (n.id) await updateNote(n.id, { isArchived: true }, { skipRevalidation: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
setFusionNotes([])
|
||||
refreshNotes(note?.notebookId)
|
||||
if (created) emitNoteChange({ type: 'created', note: created })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,16 +7,17 @@ import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { faIR } from 'date-fns/locale/fa-IR'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon } from 'lucide-react'
|
||||
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon, Network, Copy } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { NoteHistoryModal } from './note-history-modal'
|
||||
import { NoteNetworkTab } from './note-network-tab'
|
||||
import { enableNoteHistory, commitNoteHistory, getNoteHistory, deleteNoteHistoryEntry, restoreNoteVersion } from '@/app/actions/notes'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Tab = 'info' | 'versions'
|
||||
type Tab = 'info' | 'versions' | 'network'
|
||||
|
||||
interface NoteDocumentInfoPanelProps {
|
||||
note: Note
|
||||
@@ -39,6 +40,23 @@ function charCount(text: string) {
|
||||
return text.replace(/<[^>]+>/g, '').length
|
||||
}
|
||||
|
||||
function lineCount(text: string) {
|
||||
const plain = text.replace(/<[^>]+>/g, '\n')
|
||||
return plain.trim() ? plain.split('\n').length : 0
|
||||
}
|
||||
|
||||
function equationCount(text: string) {
|
||||
const block = (text.match(/\$\$[\s\S]+?\$\$/g) || []).length
|
||||
const inline = (text.match(/\$[^$\n]+?\$/g) || []).length
|
||||
return block + inline
|
||||
}
|
||||
|
||||
function imageCount(text: string) {
|
||||
const md = (text.match(/!\[[^\]]*\]\([^)]+\)/g) || []).length
|
||||
const html = (text.match(/<img\s/gi) || []).length
|
||||
return md + html
|
||||
}
|
||||
|
||||
export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }: NoteDocumentInfoPanelProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { notebooks } = useNotebooks()
|
||||
@@ -51,6 +69,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState<string | null>(null)
|
||||
const [isRestoring, setIsRestoring] = useState<string | null>(null)
|
||||
const [copiedId, setCopiedId] = useState(false)
|
||||
const locale = getLocale(language)
|
||||
|
||||
const displayNoteType = useMemo(() => {
|
||||
@@ -114,6 +133,9 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
|
||||
const words = useMemo(() => wordCount(content), [content])
|
||||
const chars = useMemo(() => charCount(content), [content])
|
||||
const lines = useMemo(() => lineCount(content), [content])
|
||||
const equations = useMemo(() => equationCount(content), [content])
|
||||
const images = useMemo(() => imageCount(content), [content])
|
||||
|
||||
const createdAt = note.createdAt ? new Date(note.createdAt as unknown as string) : null
|
||||
const updatedAt = note.contentUpdatedAt ? new Date(note.contentUpdatedAt as unknown as string) : null
|
||||
@@ -125,7 +147,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
{/* Header tabs */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border/40">
|
||||
<div className="flex gap-1">
|
||||
{(['info', 'versions'] as Tab[]).map(tab => (
|
||||
{(['info', 'versions', 'network'] as Tab[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -138,7 +160,12 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
>
|
||||
{tab === 'info' && <Info className="h-3 w-3" />}
|
||||
{tab === 'versions' && <Clock className="h-3 w-3" />}
|
||||
{tab === 'info' ? t('documentInfo.tabInfo') : t('documentInfo.tabVersions')}
|
||||
{tab === 'network' && <Network className="h-3 w-3" />}
|
||||
{tab === 'info'
|
||||
? t('documentInfo.tabInfo')
|
||||
: tab === 'versions'
|
||||
? t('documentInfo.tabVersions')
|
||||
: t('documentInfo.tabNetwork')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -168,6 +195,19 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 border-b border-border/30 divide-x divide-border/30">
|
||||
{[
|
||||
{ value: lines, label: t('documentInfo.linesLabel') },
|
||||
{ value: equations, label: t('documentInfo.equationsLabel') },
|
||||
{ value: images, label: t('documentInfo.imagesLabel') },
|
||||
].map(({ value, label }) => (
|
||||
<div key={label} className="flex flex-col items-center gap-0.5 py-3">
|
||||
<span className="text-lg font-bold font-memento-serif tabular-nums">{value}</span>
|
||||
<span className="text-[8px] uppercase tracking-widest text-muted-foreground font-semibold">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/30">
|
||||
{notebook && (
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
@@ -229,9 +269,24 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
|
||||
<div className="flex items-start gap-3 px-4 py-3">
|
||||
<Hash className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.idLabel')}</p>
|
||||
<p className="text-[11px] text-muted-foreground font-mono truncate">{note.id}</p>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<p className="text-[11px] text-muted-foreground font-mono truncate">{note.id}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(note.id).then(() => {
|
||||
setCopiedId(true)
|
||||
setTimeout(() => setCopiedId(false), 2000)
|
||||
})
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground shrink-0"
|
||||
title={t('documentInfo.copyId')}
|
||||
>
|
||||
{copiedId ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -382,6 +437,11 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── NETWORK TAB ── */}
|
||||
{activeTab === 'network' && (
|
||||
<NoteNetworkTab noteId={note.id} noteTitle={note.title || ''} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function NoteContentArea() {
|
||||
const { state, actions, readOnly, fullPage, textareaRef } = useNoteEditorContext()
|
||||
const { state, actions, readOnly, fullPage, textareaRef, note, richTextEditorRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const uploadImageFile = async (file: File) => {
|
||||
@@ -101,10 +101,12 @@ export function NoteContentArea() {
|
||||
return (
|
||||
<div className="fullpage-editor">
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
content={state.content}
|
||||
onChange={(v: string) => actions.setContent(v)}
|
||||
className="min-h-[280px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -113,10 +115,12 @@ export function NoteContentArea() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<RichTextEditor
|
||||
ref={richTextEditorRef}
|
||||
content={state.content}
|
||||
onChange={actions.setContent}
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
noteId={note.id}
|
||||
/>
|
||||
<GhostTags
|
||||
suggestions={state.filteredSuggestions}
|
||||
|
||||
@@ -6,15 +6,17 @@ import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from
|
||||
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { emitNoteChange, NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { extractImagesFromHTML } from '@/lib/utils'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
import type { RichTextEditorHandle } from '@/components/rich-text-editor'
|
||||
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||
import type { TagSuggestion } from '@/lib/ai/types'
|
||||
import type { NoteEditorState, NoteEditorActions, NoteEditorContextValue } from './types'
|
||||
@@ -32,9 +34,9 @@ interface NoteEditorProviderProps {
|
||||
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, onNoteSaved, children }: NoteEditorProviderProps) {
|
||||
const { data: session } = useSession()
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const queryClient = useQueryClient()
|
||||
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||
@@ -64,10 +66,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const richTextEditorRef = useRef<RichTextEditorHandle>(null)
|
||||
const prevNoteRef = useRef(note)
|
||||
|
||||
useEffect(() => {
|
||||
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
|
||||
const prev = prevNoteRef.current
|
||||
|
||||
if (note.id !== prev.id) {
|
||||
setTitle(note.title || '')
|
||||
setContent(note.content)
|
||||
setCheckItems(note.checkItems || [])
|
||||
@@ -79,7 +84,37 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setIsMarkdown(note.type === 'markdown')
|
||||
setShowMarkdownPreview(note.type === 'markdown')
|
||||
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
|
||||
} else {
|
||||
if (note.title !== prev.title) setTitle(note.title || '')
|
||||
// Ne pas réinitialiser le contenu quand seuls images/links changent (post-save)
|
||||
if (note.content !== prev.content) setContent(note.content)
|
||||
if (JSON.stringify(note.checkItems || []) !== JSON.stringify(prev.checkItems || [])) {
|
||||
setCheckItems(note.checkItems || [])
|
||||
}
|
||||
if (JSON.stringify(note.labels || []) !== JSON.stringify(prev.labels || [])) {
|
||||
setLabels(note.labels || [])
|
||||
}
|
||||
if (JSON.stringify(note.images || []) !== JSON.stringify(prev.images || [])) {
|
||||
setImages(note.images || [])
|
||||
}
|
||||
if (JSON.stringify(note.links || []) !== JSON.stringify(prev.links || [])) {
|
||||
setLinks(note.links || [])
|
||||
}
|
||||
if (note.color !== prev.color) setColor(note.color)
|
||||
if ((note.size || 'small') !== (prev.size || 'small')) setSize(note.size || 'small')
|
||||
const noteIsMarkdown = note.type === 'markdown'
|
||||
const prevIsMarkdown = prev.type === 'markdown'
|
||||
if (noteIsMarkdown !== prevIsMarkdown) {
|
||||
setIsMarkdown(noteIsMarkdown)
|
||||
setShowMarkdownPreview(noteIsMarkdown)
|
||||
}
|
||||
const prevReminder = prev.reminder ? new Date(prev.reminder as unknown as string).getTime() : null
|
||||
const nextReminder = note.reminder ? new Date(note.reminder as unknown as string).getTime() : null
|
||||
if (prevReminder !== nextReminder) {
|
||||
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
|
||||
}
|
||||
}
|
||||
|
||||
prevNoteRef.current = note
|
||||
}, [note])
|
||||
|
||||
@@ -178,6 +213,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
setImages(prev => prev.includes(url) ? prev : [...prev, url])
|
||||
setIsDirty(true)
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
||||
@@ -198,6 +234,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
setImages(prev => prev.includes(url) ? prev : [...prev, url])
|
||||
setIsDirty(true)
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||
}
|
||||
@@ -233,6 +270,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
if (removedUrl) {
|
||||
setRemovedImageUrls(prev => [...prev, removedUrl])
|
||||
}
|
||||
setIsDirty(true)
|
||||
}
|
||||
|
||||
const handleAddLink = async () => {
|
||||
@@ -262,9 +300,22 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
|
||||
const allImages = useMemo(() => {
|
||||
const extracted = !isMarkdown ? extractImagesFromHTML(content) : [];
|
||||
return Array.from(new Set([...images, ...extracted]));
|
||||
}, [images, content, isMarkdown]);
|
||||
const extracted = !isMarkdown ? extractImagesFromHTML(content) : []
|
||||
return Array.from(new Set([...images, ...extracted]))
|
||||
}, [images, content, isMarkdown])
|
||||
|
||||
const resolveContentForSave = useCallback((): string => {
|
||||
if (!isMarkdown) {
|
||||
const editor = richTextEditorRef.current?.getEditor()
|
||||
if (editor) return editor.getHTML()
|
||||
}
|
||||
return content
|
||||
}, [content, isMarkdown])
|
||||
|
||||
const resolveImagesForSave = useCallback((contentToSave: string): string[] => {
|
||||
const extracted = !isMarkdown ? extractImagesFromHTML(contentToSave) : []
|
||||
return Array.from(new Set([...images, ...extracted]))
|
||||
}, [images, isMarkdown])
|
||||
|
||||
const handleGenerateTitles = async () => {
|
||||
const fullContentForAI = [
|
||||
@@ -281,6 +332,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsGeneratingTitles(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/title-suggestions', {
|
||||
@@ -347,6 +401,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsReformulating(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
@@ -385,6 +442,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
@@ -411,6 +471,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
@@ -437,6 +500,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
@@ -468,6 +534,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return
|
||||
}
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/transform-markdown', {
|
||||
@@ -506,7 +575,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
setCurrentReminder(date)
|
||||
try {
|
||||
await updateNote(note.id, { reminder: date })
|
||||
await updateNote(note.id, { reminder: date }, { skipRevalidation: true })
|
||||
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
|
||||
} catch {
|
||||
toast.error(t('notebook.savingReminder'))
|
||||
@@ -516,7 +585,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const handleRemoveReminder = async () => {
|
||||
setCurrentReminder(null)
|
||||
try {
|
||||
await updateNote(note.id, { reminder: null })
|
||||
await updateNote(note.id, { reminder: null }, { skipRevalidation: true })
|
||||
toast.success(t('notes.reminderRemoved'))
|
||||
} catch {
|
||||
toast.error(t('notebook.removingReminder'))
|
||||
@@ -526,19 +595,23 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const contentToSave = resolveContentForSave()
|
||||
const imagesToSave = resolveImagesForSave(contentToSave)
|
||||
const result = await updateNote(note.id, {
|
||||
title: title.trim() || null,
|
||||
content,
|
||||
content: contentToSave,
|
||||
checkItems: null,
|
||||
labels,
|
||||
images,
|
||||
images: imagesToSave,
|
||||
links,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
isMarkdown,
|
||||
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
|
||||
size,
|
||||
})
|
||||
}, { skipRevalidation: true })
|
||||
if (contentToSave !== content) setContent(contentToSave)
|
||||
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
|
||||
prevNoteRef.current = { ...prevNoteRef.current, ...result }
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
@@ -547,7 +620,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
onNoteSaved?.(result)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
triggerRefresh()
|
||||
emitNoteChange({ type: 'updated', note: result })
|
||||
toast.success(t('notes.saved') || 'Note sauvegardée !')
|
||||
} catch (error) {
|
||||
console.error('[SAVE] updateNote failed:', error)
|
||||
@@ -630,7 +703,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
})
|
||||
toast.success(t('notes.copySuccess'))
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
triggerRefresh()
|
||||
emitNoteChange({ type: 'created', note: newNote })
|
||||
} catch (error) {
|
||||
console.error('Failed to copy note:', error)
|
||||
toast.error(t('notes.copyFailed'))
|
||||
@@ -640,12 +713,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const handleSaveInPlace = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const contentToSave = resolveContentForSave()
|
||||
const imagesToSave = resolveImagesForSave(contentToSave)
|
||||
const updatePayload = {
|
||||
title: title.trim() || null,
|
||||
content,
|
||||
content: contentToSave,
|
||||
checkItems: null,
|
||||
labels,
|
||||
images,
|
||||
images: imagesToSave,
|
||||
links,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
@@ -653,7 +728,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
|
||||
size,
|
||||
}
|
||||
const result = await updateNote(note.id, updatePayload)
|
||||
const result = await updateNote(note.id, updatePayload, { skipRevalidation: true })
|
||||
if (contentToSave !== content) setContent(contentToSave)
|
||||
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
|
||||
prevNoteRef.current = { ...prevNoteRef.current, ...result }
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
@@ -662,7 +739,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
onNoteSaved?.(result)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
triggerRefresh()
|
||||
emitNoteChange({ type: 'updated', note: result })
|
||||
setIsDirty(false)
|
||||
toast.success(t('notes.saved') || 'Saved')
|
||||
} catch (error) {
|
||||
@@ -673,17 +750,36 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveInPlaceRef = useRef(handleSaveInPlace)
|
||||
handleSaveInPlaceRef.current = handleSaveInPlace
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
handleSaveRef.current = handleSave
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullPage) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSaveInPlace()
|
||||
void handleSaveInPlaceRef.current()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [fullPage, isSaving])
|
||||
}, [fullPage])
|
||||
|
||||
useEffect(() => {
|
||||
const onRequestSave = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ noteId?: string }>).detail
|
||||
if (detail?.noteId !== note.id || readOnly) return
|
||||
if (fullPage) {
|
||||
void handleSaveInPlaceRef.current()
|
||||
} else {
|
||||
void handleSaveRef.current()
|
||||
}
|
||||
}
|
||||
window.addEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave)
|
||||
return () => window.removeEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave)
|
||||
}, [note.id, fullPage, readOnly])
|
||||
|
||||
const state: NoteEditorState = useMemo(() => ({
|
||||
title,
|
||||
@@ -792,6 +888,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
globalLabels,
|
||||
fileInputRef,
|
||||
textareaRef,
|
||||
richTextEditorRef,
|
||||
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ComparisonModal } from '@/components/comparison-modal'
|
||||
import { FusionModal } from '@/components/fusion-modal'
|
||||
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||
import { ContextualAIChat } from '@/components/contextual-ai-chat'
|
||||
import { EditorConnectionsSection } from '@/components/editor-connections-section'
|
||||
import { MemoryEchoSection } from '@/components/memory-echo-section'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,6 +25,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Note } from '@/lib/types'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface NoteEditorDialogProps {
|
||||
onClose: () => void
|
||||
@@ -33,6 +34,7 @@ interface NoteEditorDialogProps {
|
||||
export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
|
||||
|
||||
const handleSaveAndClose = async () => {
|
||||
await actions.handleSave()
|
||||
@@ -112,13 +114,10 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
|
||||
{/* Memory Echo Connections Section */}
|
||||
{!readOnly && (
|
||||
<EditorConnectionsSection
|
||||
<MemoryEchoSection
|
||||
noteId={note.id}
|
||||
onOpenNote={(noteId: string) => {
|
||||
onClose()
|
||||
window.location.href = `/home?note=${noteId}`
|
||||
}}
|
||||
onCompareNotes={(noteIds: string[]) => {
|
||||
onCompareNotes={(noteIds: string[], meta?: { similarity?: number }) => {
|
||||
setComparisonSimilarity(meta?.similarity)
|
||||
Promise.all(noteIds.map(async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
@@ -292,11 +291,24 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
{state.comparisonNotes && state.comparisonNotes.length > 0 && (
|
||||
<ComparisonModal
|
||||
isOpen={!!state.comparisonNotes}
|
||||
onClose={() => actions.setComparisonNotes([])}
|
||||
onClose={() => {
|
||||
setComparisonSimilarity(undefined)
|
||||
actions.setComparisonNotes([])
|
||||
}}
|
||||
notes={state.comparisonNotes}
|
||||
onOpenNote={(noteId: string) => {
|
||||
onClose()
|
||||
window.location.href = `/home?note=${noteId}`
|
||||
similarity={comparisonSimilarity}
|
||||
onMergeNotes={async (noteIds: string[]) => {
|
||||
const fetchedNotes = await Promise.all(noteIds.map(async (id: string) => {
|
||||
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
|
||||
}
|
||||
}))
|
||||
actions.setFusionNotes(fetchedNotes.filter((n): n is Partial<Note> => n !== null))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -21,20 +21,40 @@ import { LabelBadge } from '@/components/label-badge'
|
||||
import { NoteAttachments } from '@/components/note-attachments'
|
||||
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { useState } from 'react'
|
||||
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
|
||||
import { MemoryEchoSection } from '@/components/memory-echo-section'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface NoteEditorFullPageProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const { state, actions, note, readOnly, notebooks, fileInputRef, globalLabels } = useNoteEditorContext()
|
||||
const [docQAAttachment, setDocQAAttachment] = useState<{ id: string; fileName: string } | null>(null)
|
||||
const [attachmentsCount, setAttachmentsCount] = useState(0)
|
||||
const [uploadTrigger, setUploadTrigger] = useState(0)
|
||||
const [comparisonSimilarity, setComparisonSimilarity] = useState<number | undefined>()
|
||||
|
||||
const fetchNotesByIds = async (noteIds: string[]) => {
|
||||
const notes = 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 as Partial<Note> : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}))
|
||||
return notes.filter((n): n is Partial<Note> => n !== null)
|
||||
}
|
||||
|
||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||
|
||||
@@ -109,6 +129,19 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
<div className="max-w-3xl mx-auto w-full space-y-8 pb-32">
|
||||
<NoteContentArea />
|
||||
|
||||
{!readOnly && (
|
||||
<MemoryEchoSection
|
||||
noteId={note.id}
|
||||
onCompareNotes={async (noteIds, meta) => {
|
||||
setComparisonSimilarity(meta?.similarity)
|
||||
actions.setComparisonNotes(await fetchNotesByIds(noteIds))
|
||||
}}
|
||||
onMergeNotes={async (noteIds) => {
|
||||
actions.setFusionNotes(await fetchNotesByIds(noteIds))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NoteAttachments
|
||||
noteId={note.id}
|
||||
onOpenDocQA={(att) => setDocQAAttachment(att)}
|
||||
@@ -149,6 +182,8 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
actions.setIsProcessingAI(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/title-suggestions', {
|
||||
@@ -209,6 +244,55 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
onSave={actions.handleReminderSave}
|
||||
onRemove={actions.handleRemoveReminder}
|
||||
/>
|
||||
|
||||
{state.comparisonNotes.length > 0 && (
|
||||
<ComparisonModal
|
||||
isOpen
|
||||
onClose={() => {
|
||||
setComparisonSimilarity(undefined)
|
||||
actions.setComparisonNotes([])
|
||||
}}
|
||||
notes={state.comparisonNotes}
|
||||
similarity={comparisonSimilarity}
|
||||
onMergeNotes={async (noteIds) => {
|
||||
actions.setFusionNotes(await fetchNotesByIds(noteIds))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.fusionNotes.length > 0 && (
|
||||
<FusionModal
|
||||
isOpen
|
||||
onClose={() => actions.setFusionNotes([])}
|
||||
notes={state.fusionNotes}
|
||||
onConfirmFusion={async ({ title, content }, options) => {
|
||||
await actions.handleSaveInPlace()
|
||||
const { createNote, updateNote } = await import('@/app/actions/notes')
|
||||
await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
? [...new Set(state.fusionNotes.flatMap(n => n.labels || []))]
|
||||
: state.fusionNotes[0].labels || [],
|
||||
color: state.fusionNotes[0].color,
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
aiProvider: 'fusion',
|
||||
notebookId: state.fusionNotes[0].notebookId ?? undefined,
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const fusionNote of state.fusionNotes) {
|
||||
if (fusionNote.id) {
|
||||
await updateNote(fusionNote.id, { isArchived: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
actions.setFusionNotes([])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -39,7 +39,6 @@ interface NoteEditorToolbarProps {
|
||||
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
|
||||
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const { refreshNotes } = useRefresh()
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [shareOpen, setShareOpen] = useState(false)
|
||||
|
||||
@@ -225,8 +224,8 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
refreshNotes(note.notebookId)
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.noteDeletedToast'))
|
||||
onClose()
|
||||
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
|
||||
@@ -366,9 +365,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
await leaveSharedNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.leftShare'))
|
||||
refreshNotes(note.notebookId)
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
|
||||
@@ -4,12 +4,14 @@ import { useNoteEditorContext } from './note-editor-context'
|
||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||
import { Loader2, Sparkles } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function NoteTitleBlock() {
|
||||
const { state, actions, readOnly, fullPage } = useNoteEditorContext()
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
|
||||
if (fullPage) {
|
||||
// Adaptive font size: short = big editorial, long = smaller but still premium
|
||||
@@ -64,6 +66,8 @@ export function NoteTitleBlock() {
|
||||
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
actions.setIsProcessingAI(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai/title-suggestions', {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
|
||||
import type { RichTextEditorHandle } from '@/components/rich-text-editor'
|
||||
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||
import type { TagSuggestion } from '@/lib/ai/types'
|
||||
|
||||
@@ -129,4 +131,5 @@ export interface NoteEditorContextValue {
|
||||
globalLabels: Array<{ name: string }>
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
richTextEditorRef: RefObject<RichTextEditorHandle | null>
|
||||
}
|
||||
|
||||
145
memento-note/components/note-link-picker.tsx
Normal file
145
memento-note/components/note-link-picker.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { FileText, Loader2, Search, X } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export interface NoteLinkOption {
|
||||
id: string
|
||||
title: string | null
|
||||
notebookId: string | null
|
||||
}
|
||||
|
||||
interface NoteLinkPickerProps {
|
||||
isOpen: boolean
|
||||
query: string
|
||||
currentNoteId?: string
|
||||
onClose: () => void
|
||||
onSelect: (note: NoteLinkOption) => void
|
||||
}
|
||||
|
||||
export function NoteLinkPicker({
|
||||
isOpen,
|
||||
query,
|
||||
currentNoteId,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: NoteLinkPickerProps) {
|
||||
const { t } = useLanguage()
|
||||
const [searchQuery, setSearchQuery] = useState(query)
|
||||
const [results, setResults] = useState<NoteLinkOption[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setSearchQuery(query)
|
||||
}, [isOpen, query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setLoading(true)
|
||||
const timer = setTimeout(() => {
|
||||
const params = new URLSearchParams({ limit: '15' })
|
||||
if (searchQuery.trim()) params.set('search', searchQuery.trim())
|
||||
fetch(`/api/notes?${params}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const notes: NoteLinkOption[] = (data.data || [])
|
||||
.filter((n: { id: string }) => n.id !== currentNoteId)
|
||||
.map((n: { id: string; title: string | null; notebookId: string | null }) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
notebookId: n.notebookId,
|
||||
}))
|
||||
setResults(notes)
|
||||
})
|
||||
.catch(() => setResults([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, 250)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isOpen, searchQuery, currentNoteId])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/30 dark:bg-black/50 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0, y: 12 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-[440px] max-w-full bg-background rounded-2xl border border-border shadow-2xl flex flex-col max-h-[70vh] overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4 border-b border-border/60 flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#A47148]/10 flex items-center justify-center text-[#A47148] shrink-0">
|
||||
<FileText size={16} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">{t('richTextEditor.noteLinkPickerTitle')}</h3>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug mt-0.5">
|
||||
{t('richTextEditor.noteLinkPickerHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-full text-muted-foreground hover:bg-muted transition-colors shrink-0"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-b border-border/40">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
autoFocus
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('richTextEditor.noteLinkPickerSearch')}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-border bg-muted/30 outline-none focus:ring-2 focus:ring-[#A47148]/30"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground text-sm">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8 px-4 leading-relaxed">
|
||||
{t('richTextEditor.noteLinkPickerEmpty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{results.map(note => (
|
||||
<li key={note.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(note)}
|
||||
className="w-full text-left px-3 py-2.5 rounded-xl hover:bg-muted transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium line-clamp-2">
|
||||
{note.title?.trim() || t('documentInfo.network.untitled')}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
757
memento-note/components/note-network-tab.tsx
Normal file
757
memento-note/components/note-network-tab.tsx
Normal file
@@ -0,0 +1,757 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, HelpCircle, Loader2, Network, Sparkles, Link2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { NOTE_CHANGE_EVENT } from '@/lib/note-change-sync'
|
||||
import { openNoteInNewTab } from '@/lib/navigation/open-note'
|
||||
import {
|
||||
SEMANTIC_SIMILARITY_FLOOR,
|
||||
semanticOrbitRadius,
|
||||
semanticProximityPercent,
|
||||
semanticProximityRatio,
|
||||
} from '@/lib/ai/semantic-proximity'
|
||||
|
||||
interface NetworkNote {
|
||||
id: string
|
||||
title: string | null
|
||||
notebookId: string | null
|
||||
}
|
||||
|
||||
interface NetworkLink {
|
||||
id: string
|
||||
note: NetworkNote
|
||||
contextSnippet: string | null
|
||||
}
|
||||
|
||||
interface UnlinkedMention {
|
||||
title: string
|
||||
snippet: string
|
||||
}
|
||||
|
||||
interface SemanticConnection {
|
||||
noteId: string
|
||||
title: string | null
|
||||
notebookId: string | null
|
||||
similarity: number
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
interface EmbedHost {
|
||||
note: NetworkNote
|
||||
blockIds: string[]
|
||||
}
|
||||
|
||||
type OrbitRelationship = 'backlink' | 'outbound' | 'mention' | 'semantic' | 'embed'
|
||||
|
||||
interface OrbitNode {
|
||||
key: string
|
||||
id?: string
|
||||
title: string
|
||||
color: string
|
||||
notebookName: string
|
||||
relationship: OrbitRelationship
|
||||
snippet?: string | null
|
||||
similarity?: number
|
||||
}
|
||||
|
||||
interface NoteNetworkTabProps {
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
}
|
||||
|
||||
const MAX_GRAPH_NODES = 10
|
||||
const MAX_LIST_ITEMS = 5
|
||||
const DEFAULT_COLOR = '#71717A'
|
||||
const SEMANTIC_COLOR = '#7C3AED'
|
||||
const CX = 160
|
||||
const CY = 110
|
||||
|
||||
function orbitRadius(relationship: OrbitRelationship): number {
|
||||
switch (relationship) {
|
||||
case 'outbound': return 52
|
||||
case 'backlink': return 68
|
||||
case 'embed': return 78
|
||||
case 'semantic': return 88
|
||||
default: return 94
|
||||
}
|
||||
}
|
||||
|
||||
function cleanSnippet(text: string, max = 140): string {
|
||||
const plain = text
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\|+/g, ' ')
|
||||
.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!plain) return ''
|
||||
if (plain.length <= max) return plain
|
||||
return `${plain.slice(0, max).trim()}…`
|
||||
}
|
||||
|
||||
function SectionHelp({ label, help }: { label: string; help: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded-md text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={label}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[260px] text-left text-xs leading-relaxed">
|
||||
<p className="font-medium mb-1">{label}</p>
|
||||
<p className="text-background/85">{help}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function InteractiveOrbitGraph({
|
||||
nodes,
|
||||
extraCount,
|
||||
centerTitle,
|
||||
t,
|
||||
relationshipLabel,
|
||||
similarityFloor,
|
||||
}: {
|
||||
nodes: OrbitNode[]
|
||||
extraCount: number
|
||||
centerTitle: string
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
relationshipLabel: (rel: OrbitRelationship) => string
|
||||
similarityFloor: number
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [offsets, setOffsets] = useState<Record<string, { dx: number; dy: number }>>({})
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null)
|
||||
const [hoveredKey, setHoveredKey] = useState<string | null>(null)
|
||||
const dragRef = useRef<{
|
||||
key: string
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
baseDx: number
|
||||
baseDy: number
|
||||
moved: boolean
|
||||
} | null>(null)
|
||||
|
||||
const basePositions = useMemo(() => {
|
||||
return nodes.map((node, i) => {
|
||||
const angle = i * (nodes.length > 0 ? (2 * Math.PI) / nodes.length : 0) - Math.PI / 2
|
||||
const r = node.relationship === 'semantic' && node.similarity != null
|
||||
? semanticOrbitRadius(node.similarity, similarityFloor)
|
||||
: orbitRadius(node.relationship)
|
||||
return {
|
||||
key: node.key,
|
||||
x: CX + r * Math.cos(angle),
|
||||
y: CY + r * 0.88 * Math.sin(angle),
|
||||
}
|
||||
})
|
||||
}, [nodes, similarityFloor])
|
||||
|
||||
const nodePosition = useCallback((key: string, baseX: number, baseY: number) => {
|
||||
const o = offsets[key] || { dx: 0, dy: 0 }
|
||||
return { x: baseX + o.dx, y: baseY + o.dy }
|
||||
}, [offsets])
|
||||
|
||||
const clientToSvg = useCallback((clientX: number, clientY: number) => {
|
||||
const svg = svgRef.current
|
||||
if (!svg) return { x: 0, y: 0 }
|
||||
const pt = svg.createSVGPoint()
|
||||
pt.x = clientX
|
||||
pt.y = clientY
|
||||
const ctm = svg.getScreenCTM()
|
||||
if (!ctm) return { x: 0, y: 0 }
|
||||
return pt.matrixTransform(ctm.inverse())
|
||||
}, [])
|
||||
|
||||
const endDrag = useCallback((pointerId: number) => {
|
||||
const d = dragRef.current
|
||||
if (!d || d.pointerId !== pointerId) return
|
||||
dragRef.current = null
|
||||
setActiveKey(null)
|
||||
}, [])
|
||||
|
||||
const startDrag = useCallback((
|
||||
nodeKey: string,
|
||||
pointerId: number,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
baseDx: number,
|
||||
baseDy: number,
|
||||
) => {
|
||||
const pt = clientToSvg(clientX, clientY)
|
||||
dragRef.current = {
|
||||
key: nodeKey,
|
||||
pointerId,
|
||||
startX: pt.x,
|
||||
startY: pt.y,
|
||||
baseDx,
|
||||
baseDy,
|
||||
moved: false,
|
||||
}
|
||||
setActiveKey(nodeKey)
|
||||
}, [clientToSvg])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const d = dragRef.current
|
||||
if (!d || e.pointerId !== d.pointerId) return
|
||||
e.preventDefault()
|
||||
const pt = clientToSvg(e.clientX, e.clientY)
|
||||
const dx = d.baseDx + (pt.x - d.startX)
|
||||
const dy = d.baseDy + (pt.y - d.startY)
|
||||
if (!d.moved && Math.hypot(pt.x - d.startX, pt.y - d.startY) > 4) {
|
||||
d.moved = true
|
||||
}
|
||||
setOffsets(prev => ({ ...prev, [d.key]: { dx, dy } }))
|
||||
}
|
||||
|
||||
const onUp = (e: PointerEvent) => {
|
||||
const d = dragRef.current
|
||||
if (!d || e.pointerId !== d.pointerId) return
|
||||
const moved = d.moved
|
||||
const node = nodes.find(n => n.key === d.key)
|
||||
endDrag(e.pointerId)
|
||||
if (!moved && node?.id) {
|
||||
openNoteInNewTab(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove, { passive: false })
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
}
|
||||
}, [clientToSvg, endDrag, nodes])
|
||||
|
||||
const hoveredNode = nodes.find(n => n.key === hoveredKey) || null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/50 bg-muted/30 overflow-hidden">
|
||||
<div className="p-2 pb-0">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height="220"
|
||||
viewBox="0 0 320 220"
|
||||
className="select-none block"
|
||||
role="img"
|
||||
aria-label={t('documentInfo.network.graphTitle')}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<circle cx={CX} cy={CY} r="96" fill="none" stroke="currentColor" strokeWidth="1" strokeDasharray="3,6" className="text-border/80" />
|
||||
|
||||
{basePositions.map((base, i) => {
|
||||
const node = nodes[i]
|
||||
const { x, y } = nodePosition(node.key, base.x, base.y)
|
||||
const isSemantic = node.relationship === 'semantic'
|
||||
const isMention = node.relationship === 'mention'
|
||||
const stroke = isSemantic ? SEMANTIC_COLOR : isMention ? '#94A3B8' : '#A47148'
|
||||
const proximity = isSemantic && node.similarity != null
|
||||
? semanticProximityRatio(node.similarity, similarityFloor)
|
||||
: null
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`line-${node.key}`}
|
||||
x1={CX}
|
||||
y1={CY}
|
||||
x2={x}
|
||||
y2={y}
|
||||
stroke={stroke}
|
||||
strokeWidth={isSemantic ? 1.2 + (proximity ?? 0) * 2.2 : 1.5}
|
||||
strokeDasharray={isMention || isSemantic ? '5,4' : undefined}
|
||||
className={isSemantic ? undefined : 'opacity-55'}
|
||||
opacity={isSemantic ? 0.35 + (proximity ?? 0) * 0.5 : 0.55}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<circle cx={CX} cy={CY} r="14" fill="#A47148" stroke="var(--background)" strokeWidth="3" style={{ pointerEvents: 'none' }} />
|
||||
<circle cx={CX} cy={CY} r="4" fill="white" style={{ pointerEvents: 'none' }} />
|
||||
|
||||
{basePositions.map((base, i) => {
|
||||
const node = nodes[i]
|
||||
const { x, y } = nodePosition(node.key, base.x, base.y)
|
||||
const isActive = activeKey === node.key || hoveredKey === node.key
|
||||
const canOpen = !!node.id
|
||||
|
||||
return (
|
||||
<g key={node.key}>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={18}
|
||||
fill="transparent"
|
||||
className={cn(canOpen && 'cursor-pointer')}
|
||||
onPointerEnter={() => setHoveredKey(node.key)}
|
||||
onPointerLeave={() => {
|
||||
if (activeKey !== node.key) setHoveredKey(null)
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!canOpen) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const o = offsets[node.key] || { dx: 0, dy: 0 }
|
||||
startDrag(node.key, e.pointerId, e.clientX, e.clientY, o.dx, o.dy)
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}}
|
||||
/>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={isActive ? 10 : 7}
|
||||
fill={node.color}
|
||||
stroke={isActive ? 'var(--foreground)' : 'var(--background)'}
|
||||
strokeWidth={2}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={y + 15}
|
||||
textAnchor="middle"
|
||||
fontSize="7"
|
||||
fill="currentColor"
|
||||
className="text-muted-foreground"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{node.title.length > 13 ? `${node.title.slice(0, 12)}…` : node.title}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{extraCount > 0 && (
|
||||
<p className="text-[9px] text-right text-muted-foreground px-1 -mt-1">
|
||||
{t('documentInfo.network.moreNodes', { count: extraCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-2 mb-2 p-2.5 rounded-lg border border-border/40 bg-background min-h-[52px]">
|
||||
{hoveredNode ? (
|
||||
<div>
|
||||
<div className="flex justify-between gap-2 text-[9px] text-muted-foreground mb-0.5">
|
||||
<span className="truncate">{hoveredNode.notebookName}</span>
|
||||
<span className="font-semibold shrink-0 text-foreground">{relationshipLabel(hoveredNode.relationship)}</span>
|
||||
</div>
|
||||
<p className="font-medium text-xs leading-snug">{hoveredNode.title}</p>
|
||||
{hoveredNode.similarity != null && (
|
||||
<p className="text-[10px] text-violet-600 dark:text-violet-400 mt-0.5">
|
||||
{t('documentInfo.network.affinityLine', {
|
||||
percentage: semanticProximityPercent(hoveredNode.similarity, similarityFloor),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{hoveredNode.snippet && (
|
||||
<p className="text-[10px] text-muted-foreground line-clamp-2 mt-1">{cleanSnippet(hoveredNode.snippet)}</p>
|
||||
)}
|
||||
{hoveredNode.id && (
|
||||
<p className="text-[9px] text-muted-foreground mt-1">{t('documentInfo.network.clickToOpen')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground text-center leading-relaxed">
|
||||
{t('documentInfo.network.dragHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 px-3 pb-2.5 text-[9px] text-muted-foreground border-t border-border/30 pt-2">
|
||||
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[#A47148]" />{t('documentInfo.network.legendCenter')}</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-600" />{t('documentInfo.network.legendSemantic')}</span>
|
||||
<span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-[#A47148]/60" />{t('documentInfo.network.legendWiki')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteNetworkTab({ noteId, noteTitle }: NoteNetworkTabProps) {
|
||||
const { t } = useLanguage()
|
||||
const { notebooks } = useNotebooks()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [backlinks, setBacklinks] = useState<NetworkLink[]>([])
|
||||
const [outbound, setOutbound] = useState<NetworkLink[]>([])
|
||||
const [unlinkedMentions, setUnlinkedMentions] = useState<UnlinkedMention[]>([])
|
||||
const [semanticConnections, setSemanticConnections] = useState<SemanticConnection[]>([])
|
||||
const [embedHosts, setEmbedHosts] = useState<EmbedHost[]>([])
|
||||
const [consentRequired, setConsentRequired] = useState(false)
|
||||
const [similarityFloor, setSimilarityFloor] = useState(SEMANTIC_SIMILARITY_FLOOR)
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
const [showAllSemantic, setShowAllSemantic] = useState(false)
|
||||
const [showWiki, setShowWiki] = useState(true)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
const loadNetwork = useCallback(() => {
|
||||
if (!noteId) return
|
||||
setLoading(true)
|
||||
fetch(`/api/notes/${noteId}/network`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setBacklinks(data.backlinks || [])
|
||||
setOutbound(data.outbound || [])
|
||||
setUnlinkedMentions(data.unlinkedMentions || [])
|
||||
setSemanticConnections(data.semanticConnections || [])
|
||||
setEmbedHosts(data.embedHosts || [])
|
||||
setConsentRequired(!!data.consentRequired)
|
||||
setSimilarityFloor(typeof data.similarityFloor === 'number' ? data.similarityFloor : SEMANTIC_SIMILARITY_FLOOR)
|
||||
})
|
||||
.catch(() => {
|
||||
setBacklinks([])
|
||||
setOutbound([])
|
||||
setUnlinkedMentions([])
|
||||
setSemanticConnections([])
|
||||
setEmbedHosts([])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [noteId])
|
||||
|
||||
useEffect(() => {
|
||||
loadNetwork()
|
||||
}, [loadNetwork, refreshKey])
|
||||
|
||||
useEffect(() => {
|
||||
const onNoteChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail
|
||||
if (detail?.type === 'updated' && detail.note?.id === noteId) {
|
||||
setRefreshKey(k => k + 1)
|
||||
}
|
||||
}
|
||||
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||
}, [noteId])
|
||||
|
||||
const colorForNotebook = (notebookId: string | null) =>
|
||||
notebooks.find(n => n.id === notebookId)?.color || DEFAULT_COLOR
|
||||
|
||||
const notebookNameFor = (notebookId: string | null) =>
|
||||
notebooks.find(n => n.id === notebookId)?.name || t('documentInfo.network.unknownNotebook')
|
||||
|
||||
const sortedSemantic = useMemo(
|
||||
() => [...semanticConnections].sort((a, b) => b.similarity - a.similarity),
|
||||
[semanticConnections]
|
||||
)
|
||||
|
||||
const graphNodes = useMemo(() => {
|
||||
const nodes: OrbitNode[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (node: OrbitNode) => {
|
||||
if (node.id) {
|
||||
if (seen.has(node.id)) return
|
||||
seen.add(node.id)
|
||||
}
|
||||
nodes.push(node)
|
||||
}
|
||||
|
||||
outbound.forEach(link => {
|
||||
if (!link.note) return
|
||||
push({
|
||||
key: `out-${link.id}`,
|
||||
id: link.note.id,
|
||||
title: link.note.title || t('documentInfo.network.untitled'),
|
||||
color: colorForNotebook(link.note.notebookId),
|
||||
notebookName: notebookNameFor(link.note.notebookId),
|
||||
relationship: 'outbound',
|
||||
snippet: link.contextSnippet,
|
||||
})
|
||||
})
|
||||
|
||||
backlinks.forEach(link => {
|
||||
if (!link.note) return
|
||||
push({
|
||||
key: `in-${link.id}`,
|
||||
id: link.note.id,
|
||||
title: link.note.title || t('documentInfo.network.untitled'),
|
||||
color: colorForNotebook(link.note.notebookId),
|
||||
notebookName: notebookNameFor(link.note.notebookId),
|
||||
relationship: 'backlink',
|
||||
snippet: link.contextSnippet,
|
||||
})
|
||||
})
|
||||
|
||||
sortedSemantic.forEach(conn => {
|
||||
push({
|
||||
key: `sem-${conn.noteId}`,
|
||||
id: conn.noteId,
|
||||
title: conn.title || t('documentInfo.network.untitled'),
|
||||
color: SEMANTIC_COLOR,
|
||||
notebookName: notebookNameFor(conn.notebookId),
|
||||
relationship: 'semantic',
|
||||
snippet: conn.excerpt,
|
||||
similarity: conn.similarity,
|
||||
})
|
||||
})
|
||||
|
||||
embedHosts.forEach((host, i) => {
|
||||
push({
|
||||
key: `embed-${i}-${host.note.id}`,
|
||||
id: host.note.id,
|
||||
title: host.note.title || t('documentInfo.network.untitled'),
|
||||
color: colorForNotebook(host.note.notebookId),
|
||||
notebookName: notebookNameFor(host.note.notebookId),
|
||||
relationship: 'embed',
|
||||
snippet: t('documentInfo.network.embedSnippetOne'),
|
||||
})
|
||||
})
|
||||
|
||||
return nodes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sortedSemantic, outbound, backlinks, embedHosts, notebooks, t])
|
||||
|
||||
const orbitNodes = graphNodes.slice(0, MAX_GRAPH_NODES)
|
||||
const extraCount = Math.max(0, graphNodes.length - MAX_GRAPH_NODES)
|
||||
|
||||
const relationshipLabel = (rel: OrbitRelationship) => {
|
||||
switch (rel) {
|
||||
case 'backlink': return t('documentInfo.network.inboundShort')
|
||||
case 'outbound': return t('documentInfo.network.outboundShort')
|
||||
case 'semantic': return t('documentInfo.network.semanticShort')
|
||||
case 'embed': return t('documentInfo.network.embedShort')
|
||||
default: return t('documentInfo.network.mentionShort')
|
||||
}
|
||||
}
|
||||
|
||||
const hasWiki = backlinks.length > 0 || outbound.length > 0 || unlinkedMentions.length > 0
|
||||
const visibleSemantic = showAllSemantic ? sortedSemantic : sortedSemantic.slice(0, MAX_LIST_ITEMS)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<p className="text-xs">{t('documentInfo.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isEmpty =
|
||||
!hasWiki && sortedSemantic.length === 0 && embedHosts.length === 0
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('documentInfo.network.graphTitle')}</h4>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed mt-1">{t('documentInfo.network.intro')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(v => !v)}
|
||||
className="shrink-0 p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label={t('documentInfo.network.helpToggle')}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{helpOpen && (
|
||||
<div className="rounded-xl border border-border/60 bg-muted/30 p-3 space-y-2 text-[11px] text-muted-foreground leading-relaxed">
|
||||
<p className="font-medium text-foreground text-xs">{t('documentInfo.network.helpTitle')}</p>
|
||||
<p>{t('documentInfo.network.helpGraph')}</p>
|
||||
<p>{t('documentInfo.network.helpSemantic')}</p>
|
||||
<p>{t('documentInfo.network.helpWiki')}</p>
|
||||
<p>{t('documentInfo.network.helpEmbed')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="text-center py-8 px-4 border border-dashed border-border/60 rounded-xl text-muted-foreground space-y-2">
|
||||
<Network className="h-7 w-7 mx-auto opacity-30" />
|
||||
<p className="text-xs leading-relaxed">{t('documentInfo.network.empty')}</p>
|
||||
{consentRequired && (
|
||||
<p className="text-[11px] opacity-80">{t('documentInfo.network.consentHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{orbitNodes.length > 0 && (
|
||||
<InteractiveOrbitGraph
|
||||
nodes={orbitNodes}
|
||||
extraCount={extraCount}
|
||||
centerTitle={noteTitle || t('documentInfo.network.untitled')}
|
||||
t={t}
|
||||
relationshipLabel={relationshipLabel}
|
||||
similarityFloor={similarityFloor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sortedSemantic.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-violet-600" />
|
||||
<h5 className="text-xs font-semibold">{t('documentInfo.network.semanticListTitle')}</h5>
|
||||
<SectionHelp
|
||||
label={t('documentInfo.network.semanticListTitle')}
|
||||
help={t('documentInfo.network.semanticListHelp')}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">{sortedSemantic.length}</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{visibleSemantic.map(conn => (
|
||||
<button
|
||||
key={conn.noteId}
|
||||
type="button"
|
||||
onClick={() => openNoteInNewTab(conn.noteId)}
|
||||
className="w-full text-left p-3 rounded-xl border border-violet-500/15 bg-violet-500/[0.04] hover:border-violet-500/35 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs font-medium leading-snug line-clamp-2">{conn.title || t('documentInfo.network.untitled')}</span>
|
||||
<span className="text-[10px] font-semibold text-violet-700 dark:text-violet-300 shrink-0">
|
||||
{semanticProximityPercent(conn.similarity, similarityFloor)} %
|
||||
</span>
|
||||
</div>
|
||||
{conn.excerpt && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1 line-clamp-2 leading-relaxed">{cleanSnippet(conn.excerpt)}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{sortedSemantic.length > MAX_LIST_ITEMS && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllSemantic(v => !v)}
|
||||
className="text-[11px] text-violet-700 dark:text-violet-300 font-medium hover:underline"
|
||||
>
|
||||
{showAllSemantic
|
||||
? t('documentInfo.network.showLess')
|
||||
: t('documentInfo.network.showMoreSemantic', { count: sortedSemantic.length - MAX_LIST_ITEMS })}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{embedHosts.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h5 className="text-xs font-semibold">{t('documentInfo.network.embedListTitle')}</h5>
|
||||
<SectionHelp label={t('documentInfo.network.embedListTitle')} help={t('documentInfo.network.helpEmbed')} />
|
||||
</div>
|
||||
{embedHosts.map(host => (
|
||||
<button
|
||||
key={host.note.id}
|
||||
type="button"
|
||||
onClick={() => openNoteInNewTab(host.note.id)}
|
||||
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/40 transition-all"
|
||||
>
|
||||
<p className="text-xs font-medium line-clamp-2">{host.note.title || t('documentInfo.network.untitled')}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{t('documentInfo.network.embedSnippetOne')}</p>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasWiki && (
|
||||
<section className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWiki(v => !v)}
|
||||
className="flex items-center gap-2 w-full text-left group"
|
||||
>
|
||||
<h5 className="text-xs font-semibold text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{t('documentInfo.network.wikiSectionTitle')}
|
||||
</h5>
|
||||
{showWiki ? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</button>
|
||||
|
||||
{showWiki && (
|
||||
<div className="space-y-3 pl-0.5">
|
||||
{backlinks.length > 0 && (
|
||||
<WikiList
|
||||
title={t('documentInfo.network.inboundList', { count: backlinks.length })}
|
||||
help={t('documentInfo.network.inboundHelp')}
|
||||
links={backlinks}
|
||||
onOpen={openNoteInNewTab}
|
||||
untitled={t('documentInfo.network.untitled')}
|
||||
/>
|
||||
)}
|
||||
{outbound.length > 0 && (
|
||||
<WikiList
|
||||
title={t('documentInfo.network.outboundList', { count: outbound.length })}
|
||||
help={t('documentInfo.network.outboundHelp')}
|
||||
links={outbound}
|
||||
onOpen={openNoteInNewTab}
|
||||
untitled={t('documentInfo.network.untitled')}
|
||||
/>
|
||||
)}
|
||||
{unlinkedMentions.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">
|
||||
{t('documentInfo.network.unlinkedList', { count: unlinkedMentions.length })}
|
||||
</p>
|
||||
<SectionHelp label={t('documentInfo.network.unlinkedListTitle')} help={t('documentInfo.network.unlinkedHelp')} />
|
||||
</div>
|
||||
{unlinkedMentions.map((m, i) => (
|
||||
<div key={i} className="p-2.5 rounded-lg border border-border/40 bg-muted/20 text-[10px] text-muted-foreground leading-relaxed">
|
||||
<span className="font-medium text-foreground">[[{m.title}]]</span>
|
||||
{m.snippet && cleanSnippet(m.snippet) && (
|
||||
<span className="block mt-1 italic">{cleanSnippet(m.snippet, 100)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{backlinks.length === 0 && outbound.length === 0 && unlinkedMentions.length === 0 && (
|
||||
<p className="text-[11px] text-muted-foreground italic">{t('documentInfo.network.noWikiYet')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WikiList({
|
||||
title,
|
||||
help,
|
||||
links,
|
||||
onOpen,
|
||||
untitled,
|
||||
}: {
|
||||
title: string
|
||||
help: string
|
||||
links: NetworkLink[]
|
||||
onOpen: (id: string) => void
|
||||
untitled: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground">{title}</p>
|
||||
<SectionHelp label={title} help={help} />
|
||||
</div>
|
||||
{links.map(link => (
|
||||
<button
|
||||
key={link.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(link.note.id)}
|
||||
className="w-full text-left p-2.5 rounded-lg border border-border/40 hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<p className="text-xs font-medium truncate">{link.note.title || untitled}</p>
|
||||
{link.contextSnippet && (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5 line-clamp-2">{cleanSnippet(link.contextSnippet)}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
242
memento-note/components/notebook-hierarchy-panel.tsx
Normal file
242
memento-note/components/notebook-hierarchy-panel.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Check,
|
||||
Search,
|
||||
StickyNote,
|
||||
} from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
|
||||
export type NotebookPickerItem = {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string | null
|
||||
parentId?: string | null
|
||||
trashedAt?: Date | string | null
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 280
|
||||
const PANEL_HEIGHT = 350
|
||||
const VIEWPORT_PADDING = 8
|
||||
|
||||
export function getNotebookPickerPosition(
|
||||
rect: DOMRect,
|
||||
options?: { preferDropUp?: boolean; align?: 'start' | 'end'; panelWidth?: number; panelHeight?: number },
|
||||
): React.CSSProperties {
|
||||
const panelWidth = options?.panelWidth ?? PANEL_WIDTH
|
||||
const panelHeight = options?.panelHeight ?? PANEL_HEIGHT
|
||||
const align = options?.align ?? 'start'
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
let left = align === 'end' ? rect.right - panelWidth : rect.left
|
||||
left = Math.max(VIEWPORT_PADDING, Math.min(left, viewportWidth - panelWidth - VIEWPORT_PADDING))
|
||||
|
||||
const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_PADDING
|
||||
const spaceAbove = rect.top - VIEWPORT_PADDING
|
||||
const preferDropUp = options?.preferDropUp ?? false
|
||||
const openUp = preferDropUp || (spaceBelow < panelHeight && spaceAbove > spaceBelow)
|
||||
|
||||
if (openUp) {
|
||||
const bottom = viewportHeight - rect.top + VIEWPORT_PADDING
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: Math.max(VIEWPORT_PADDING, bottom),
|
||||
left,
|
||||
width: panelWidth,
|
||||
maxHeight: Math.min(panelHeight, rect.top - VIEWPORT_PADDING * 2),
|
||||
zIndex: 9999,
|
||||
}
|
||||
}
|
||||
|
||||
const top = rect.bottom + VIEWPORT_PADDING
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: Math.min(top, viewportHeight - VIEWPORT_PADDING),
|
||||
left,
|
||||
width: panelWidth,
|
||||
maxHeight: Math.min(panelHeight, viewportHeight - top - VIEWPORT_PADDING),
|
||||
zIndex: 9999,
|
||||
}
|
||||
}
|
||||
|
||||
type NotebookHierarchyPanelProps = {
|
||||
notebooks: NotebookPickerItem[]
|
||||
selectedId?: string | null
|
||||
onSelect: (id: string) => void
|
||||
onClose: () => void
|
||||
searchPlaceholder?: string
|
||||
footerLabel?: string
|
||||
closeLabel?: string
|
||||
showGeneralNotes?: boolean
|
||||
generalNotesLabel?: string
|
||||
onSelectGeneralNotes?: () => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function NotebookHierarchyPanel({
|
||||
notebooks,
|
||||
selectedId = null,
|
||||
onSelect,
|
||||
onClose,
|
||||
searchPlaceholder = 'Filter notebooks…',
|
||||
footerLabel,
|
||||
closeLabel = 'Close',
|
||||
showGeneralNotes = false,
|
||||
generalNotesLabel = 'General Notes',
|
||||
onSelectGeneralNotes,
|
||||
className = '',
|
||||
style,
|
||||
}: NotebookHierarchyPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const activeNotebooks = useMemo(
|
||||
() => notebooks.filter((nb) => !nb.trashedAt),
|
||||
[notebooks],
|
||||
)
|
||||
|
||||
const toggleExpand = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
const next = new Set(expandedIds)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
setExpandedIds(next)
|
||||
}
|
||||
|
||||
const renderTree = (parentId: string | null | undefined, level = 0): React.ReactNode => {
|
||||
const children = activeNotebooks.filter((nb) => (nb.parentId ?? null) === (parentId ?? null))
|
||||
if (children.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={level > 0 ? 'ms-4 border-s border-border/40 ps-2' : ''}>
|
||||
{children.map((notebook) => {
|
||||
const isExpanded = expandedIds.has(notebook.id) || searchQuery.length > 0
|
||||
const hasChildren = activeNotebooks.some((nb) => nb.parentId === notebook.id)
|
||||
const isSelected = selectedId === notebook.id
|
||||
|
||||
if (searchQuery && !notebook.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
const hasMatchingChild = (id: string): boolean => {
|
||||
const kids = activeNotebooks.filter((nb) => nb.parentId === id)
|
||||
return kids.some(
|
||||
(nb) =>
|
||||
nb.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(nb.id),
|
||||
)
|
||||
}
|
||||
if (!hasMatchingChild(notebook.id)) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={notebook.id} className="select-none">
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelect(notebook.id)
|
||||
if (!searchQuery) onClose()
|
||||
}}
|
||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
|
||||
${isSelected ? 'bg-brand-accent/15 text-brand-accent font-bold dark:bg-brand-accent/20' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
|
||||
>
|
||||
<div className="w-4 flex items-center justify-center">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleExpand(e, notebook.id)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`p-1 rounded ${isSelected ? 'bg-brand-accent/25 dark:bg-brand-accent/30' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}
|
||||
>
|
||||
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</div>
|
||||
|
||||
<span className="text-[13px] truncate flex-1">{notebook.name}</span>
|
||||
|
||||
{isSelected && <Check size={14} className="opacity-60 shrink-0" />}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{renderTree(notebook.id, level + 1)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={style}
|
||||
className={`bg-card border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col min-w-0 ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-3 border-b border-border/40 bg-card/50 dark:bg-white/5 shrink-0">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute start-3 top-1/2 -translate-y-1/2 text-concrete" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full bg-card border border-border rounded-lg ps-9 pe-4 py-2 text-xs outline-none focus:border-brand-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar p-2">
|
||||
{showGeneralNotes && onSelectGeneralNotes && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectGeneralNotes()
|
||||
onClose()
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all hover:bg-muted dark:hover:bg-white/5 text-ink mb-1"
|
||||
>
|
||||
<div className="p-1 rounded bg-muted/50 dark:bg-white/5">
|
||||
<StickyNote size={13} />
|
||||
</div>
|
||||
<span className="text-[13px] truncate flex-1 text-start">{generalNotesLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
{renderTree(null)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/40 bg-card/30 dark:bg-white/5 flex justify-between items-center px-4 shrink-0">
|
||||
{footerLabel ? (
|
||||
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">{footerLabel}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="text-[10px] font-bold text-brand-accent hover:underline">
|
||||
{closeLabel}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { X, FolderOpen } from 'lucide-react'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
interface NotebookSuggestionToastProps {
|
||||
@@ -22,6 +23,7 @@ export function NotebookSuggestionToast({
|
||||
onMoveToNotebook
|
||||
}: NotebookSuggestionToastProps) {
|
||||
const { t } = useLanguage()
|
||||
const { hasAiConsent } = useAiConsent()
|
||||
const [suggestion, setSuggestion] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [visible, setVisible] = useState(true)
|
||||
@@ -47,6 +49,8 @@ export function NotebookSuggestionToast({
|
||||
// Fetch suggestion when component mounts
|
||||
useEffect(() => {
|
||||
const fetchSuggestion = async () => {
|
||||
if (!hasAiConsent) return
|
||||
|
||||
// Only suggest if content is long enough (> 20 words)
|
||||
const wordCount = noteContent.trim().split(/\s+/).length
|
||||
if (wordCount < 20) {
|
||||
@@ -78,7 +82,7 @@ export function NotebookSuggestionToast({
|
||||
}
|
||||
|
||||
fetchSuggestion()
|
||||
}, [noteContent])
|
||||
}, [noteContent, hasAiConsent])
|
||||
|
||||
const handleDismiss = () => {
|
||||
setVisible(false)
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Loader2, FileText, RefreshCw, Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import type { NotebookSummary } from '@/lib/ai/services'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
@@ -29,6 +30,7 @@ export function NotebookSummaryDialog({
|
||||
notebookName,
|
||||
}: NotebookSummaryDialogProps) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [summary, setSummary] = useState<NotebookSummary | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [regenerating, setRegenerating] = useState(false)
|
||||
@@ -46,6 +48,9 @@ export function NotebookSummaryDialog({
|
||||
const fetchSummary = async () => {
|
||||
if (!notebookId) return
|
||||
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/notebook-summary', {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition, useEffect } from 'react'
|
||||
import { useState, useTransition, useEffect, useRef } from 'react'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { getNoteFeedImage, getNotePlainExcerpt, getNoteDisplayTitle } from '@/lib/note-preview'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { emitNoteChange, type NoteCollectionActions } from '@/lib/note-change-sync'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen } from 'lucide-react'
|
||||
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen, FileText } from 'lucide-react'
|
||||
import { useLabelsQuery } from '@/lib/query-hooks'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
@@ -17,10 +17,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { MoveToNotebookPickerPortal } from '@/components/move-to-notebook-picker'
|
||||
import { deleteNote, toggleArchive, togglePin, updateNote } from '@/app/actions/notes'
|
||||
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
@@ -35,7 +33,7 @@ type NotesEditorialViewProps = {
|
||||
onOpen: (note: Note, readOnly?: boolean) => void
|
||||
notebookName?: string
|
||||
onOpenHistory?: (note: Note) => void
|
||||
}
|
||||
} & NoteCollectionActions
|
||||
|
||||
function formatNoteDate(date: Date | string, language: string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
@@ -49,23 +47,37 @@ function formatNoteDate(date: Date | string, language: string): string {
|
||||
return `${month.toUpperCase()} ${day}, ${year}`
|
||||
}
|
||||
|
||||
function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
export function EditorialNoteMenu({
|
||||
note,
|
||||
onOpen,
|
||||
onOpenHistory,
|
||||
onTogglePin,
|
||||
onDeleteNote,
|
||||
onArchiveNote,
|
||||
onMoveToNotebook,
|
||||
onNotePatch,
|
||||
}: {
|
||||
note: Note
|
||||
onOpen: (note: Note) => void
|
||||
onOpenHistory?: (note: Note) => void
|
||||
}) {
|
||||
} & NoteCollectionActions) {
|
||||
const { t } = useLanguage()
|
||||
const { refreshNotes } = useRefresh()
|
||||
const { notebooks } = useNotebooks()
|
||||
const [, startTransition] = useTransition()
|
||||
const [showReminder, setShowReminder] = useState(false)
|
||||
const [movePickerOpen, setMovePickerOpen] = useState(false)
|
||||
const menuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onDeleteNote) {
|
||||
onDeleteNote(note)
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
refreshNotes(note?.notebookId)
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
@@ -75,10 +87,14 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
|
||||
const handleArchive = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onArchiveNote) {
|
||||
onArchiveNote(note)
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await toggleArchive(note.id, !note.isArchived)
|
||||
refreshNotes(note?.notebookId)
|
||||
await toggleArchive(note.id, !note.isArchived, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, isArchived: !note.isArchived } })
|
||||
toast.success(note.isArchived ? (t('notes.unarchived') || 'Désarchivée') : (t('notes.archived') || 'Archivée'))
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
@@ -88,10 +104,15 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
|
||||
const handlePin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (onTogglePin) {
|
||||
onTogglePin(note)
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await togglePin(note.id, !note.isPinned)
|
||||
refreshNotes(note?.notebookId)
|
||||
const nextPinned = !note.isPinned
|
||||
await togglePin(note.id, nextPinned, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, isPinned: nextPinned } })
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
@@ -99,10 +120,14 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
}
|
||||
|
||||
const handleMoveToNotebook = (notebookId: string | null) => {
|
||||
if (onMoveToNotebook) {
|
||||
onMoveToNotebook(note, notebookId)
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateNote(note.id, { notebookId })
|
||||
refreshNotes(note?.notebookId)
|
||||
await updateNote(note.id, { notebookId }, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'updated', note: { ...note, notebookId } })
|
||||
toast.success(t('notebookSuggestion.movedToNotebook') || 'Note déplacée')
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
@@ -110,11 +135,28 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
})
|
||||
}
|
||||
|
||||
const patchReminder = (reminder: Date | null) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateNote(note.id, { reminder }, { skipRevalidation: true })
|
||||
const patch = { reminder: reminder?.toISOString() ?? null }
|
||||
onNotePatch?.(note.id, patch)
|
||||
emitNoteChange({ type: 'updated', note: { ...note, reminder: patch.reminder } })
|
||||
setShowReminder(false)
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
|
||||
<button className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground">
|
||||
<button
|
||||
ref={menuTriggerRef}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-md hover:bg-muted/60 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -147,24 +189,15 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Déplacer vers un carnet */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger onClick={e => e.stopPropagation()}>
|
||||
<FolderOpen className="h-4 w-4 me-2 text-foreground/50" />
|
||||
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-52">
|
||||
<DropdownMenuItem onClick={e => { e.stopPropagation(); handleMoveToNotebook(null) }}>
|
||||
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold me-2 shrink-0">N</span>
|
||||
{t('notebookSuggestion.generalNotes') || 'Notes générales'}
|
||||
</DropdownMenuItem>
|
||||
{notebooks.map((nb: any) => (
|
||||
<DropdownMenuItem key={nb.id} onClick={e => { e.stopPropagation(); handleMoveToNotebook(nb.id) }}>
|
||||
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold me-2 shrink-0">{nb.name.charAt(0).toUpperCase()}</span>
|
||||
{nb.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setMovePickerOpen(true)
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 me-2 text-foreground/50" />
|
||||
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive focus:bg-destructive/10">
|
||||
@@ -174,25 +207,24 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<MoveToNotebookPickerPortal
|
||||
open={movePickerOpen}
|
||||
onOpenChange={setMovePickerOpen}
|
||||
anchorRef={menuTriggerRef}
|
||||
notebooks={notebooks}
|
||||
currentNotebookId={note.notebookId}
|
||||
onSelect={handleMoveToNotebook}
|
||||
align="end"
|
||||
preferDropUp
|
||||
/>
|
||||
|
||||
{/* ReminderDialog hors du DropdownMenu pour éviter les conflits de portail */}
|
||||
<ReminderDialog
|
||||
open={showReminder}
|
||||
onOpenChange={setShowReminder}
|
||||
currentReminder={note.reminder ? new Date(note.reminder) : null}
|
||||
onSave={(date) => {
|
||||
startTransition(async () => {
|
||||
await updateNote(note.id, { reminder: date })
|
||||
refreshNotes(note?.notebookId)
|
||||
setShowReminder(false)
|
||||
})
|
||||
}}
|
||||
onRemove={() => {
|
||||
startTransition(async () => {
|
||||
await updateNote(note.id, { reminder: null })
|
||||
refreshNotes(note?.notebookId)
|
||||
setShowReminder(false)
|
||||
})
|
||||
}}
|
||||
onSave={(date) => patchReminder(date)}
|
||||
onRemove={() => patchReminder(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -209,13 +241,14 @@ function EditorialThumbnail({
|
||||
note,
|
||||
title,
|
||||
aiIllustrationEnabled,
|
||||
onNoteIllustrationGenerated,
|
||||
}: {
|
||||
note: Note
|
||||
title: string
|
||||
aiIllustrationEnabled: boolean
|
||||
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const { refreshNotes } = useRefresh()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const img = getNoteFeedImage(note)
|
||||
|
||||
@@ -224,12 +257,12 @@ function EditorialThumbnail({
|
||||
if (!aiIllustrationEnabled || busy || img) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await generateNoteIllustrationSvg(note.id)
|
||||
const res = await generateNoteIllustrationSvg(note.id, { skipRevalidation: true })
|
||||
if (!res.ok) {
|
||||
toast.error(res.error)
|
||||
} else {
|
||||
toast.success(t('notes.illustrationGenerated') || 'Illustration générée')
|
||||
refreshNotes(note?.notebookId)
|
||||
await onNoteIllustrationGenerated?.(note.id)
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
@@ -253,7 +286,7 @@ function EditorialThumbnail({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<NoteThumbnailPlaceholder title={title} noteId={note.id} />
|
||||
<NoteThumbnailPlaceholder noteId={note.id} />
|
||||
{aiIllustrationEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -272,12 +305,8 @@ function EditorialThumbnail({
|
||||
)
|
||||
}
|
||||
|
||||
/** SVG thumbnail for notes without an image */
|
||||
function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: string }) {
|
||||
// Try to extract the first emoji from the title
|
||||
const emojiMatch = title.match(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u)
|
||||
const emoji = emojiMatch?.[0]
|
||||
const letter = title.replace(/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu, '').trim()[0]?.toUpperCase() || '?'
|
||||
/** SVG thumbnail for notes without an image — icône document (ref. architectural-grid), pas initiale */
|
||||
function NoteThumbnailPlaceholder({ noteId }: { noteId: string }) {
|
||||
const hue = stringToHue(noteId)
|
||||
|
||||
return (
|
||||
@@ -285,7 +314,6 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
|
||||
className="h-full w-full flex items-center justify-center relative overflow-hidden"
|
||||
style={{ background: `linear-gradient(145deg, hsl(${hue} 25% var(--thumb-lightness-1, 94%)) 0%, hsl(${hue} 18% var(--thumb-lightness-2, 87%)) 100%)` }}
|
||||
>
|
||||
{/* Decorative concentric circles */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
viewBox="0 0 224 168"
|
||||
@@ -299,25 +327,12 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
|
||||
<line x1="22" y1="84" x2="202" y2="84" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
|
||||
<line x1="112" y1="4" x2="112" y2="164" stroke="currentColor" strokeWidth="0.4" opacity="0.15" />
|
||||
</svg>
|
||||
{emoji ? (
|
||||
<span
|
||||
className="relative text-5xl leading-none select-none"
|
||||
style={{ filter: `drop-shadow(0 2px 8px hsl(${hue} 40% 40% / 0.2))` }}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="relative font-memento-serif font-bold select-none leading-none"
|
||||
style={{
|
||||
fontSize: '4.5rem',
|
||||
color: `hsl(${hue} 35% 35%)`,
|
||||
opacity: 0.35,
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
)}
|
||||
<FileText
|
||||
size={40}
|
||||
strokeWidth={1.25}
|
||||
className="relative text-muted-foreground/45"
|
||||
style={{ color: `hsl(${hue} 25% 45%)` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -339,6 +354,12 @@ export function NotesEditorialView({
|
||||
onOpen,
|
||||
notebookName,
|
||||
onOpenHistory,
|
||||
onTogglePin,
|
||||
onDeleteNote,
|
||||
onArchiveNote,
|
||||
onMoveToNotebook,
|
||||
onNotePatch,
|
||||
onNoteIllustrationGenerated,
|
||||
}: NotesEditorialViewProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
@@ -400,7 +421,16 @@ export function NotesEditorialView({
|
||||
className="absolute top-0 end-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<EditorialNoteMenu note={note} onOpen={onOpen} onOpenHistory={onOpenHistory} />
|
||||
<EditorialNoteMenu
|
||||
note={note}
|
||||
onOpen={onOpen}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onTogglePin={onTogglePin}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNotePatch={onNotePatch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="font-memento-serif text-2xl font-medium text-foreground flex items-center justify-between">
|
||||
@@ -411,7 +441,12 @@ export function NotesEditorialView({
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} />
|
||||
<EditorialThumbnail
|
||||
note={note}
|
||||
title={title}
|
||||
aiIllustrationEnabled={aiIllustrationEnabled}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
/>
|
||||
<div className="space-y-3 flex-1">
|
||||
{note.labels && note.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
941
memento-note/components/notes-list-views.tsx
Normal file
941
memento-note/components/notes-list-views.tsx
Normal file
@@ -0,0 +1,941 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useTransition, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||||
import type { NoteCollectionActions } from '@/lib/note-change-sync'
|
||||
import { getNoteDisplayTitle, getNoteFeedImage, getNotePlainExcerpt, prepareNoteIllustrationForGrid } from '@/lib/note-preview'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useLabelsQuery } from '@/lib/query-hooks'
|
||||
import { updateNote } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
import { motion } from 'motion/react'
|
||||
import { MoveToNotebookPicker } from '@/components/move-to-notebook-picker'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Pin,
|
||||
FileText,
|
||||
Link2,
|
||||
CheckSquare,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Wind,
|
||||
Trash2,
|
||||
FolderOpen,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
|
||||
export type NotesLayoutMode = 'grid' | 'list' | 'table'
|
||||
export type NotesViewType = 'notes' | 'tasks'
|
||||
|
||||
type TaskItem = {
|
||||
id: string
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
text: string
|
||||
completed: boolean
|
||||
lineIndex: number
|
||||
}
|
||||
|
||||
function getNoteTasksStats(content: string) {
|
||||
const lines = (content || '').split('\n')
|
||||
let total = 0
|
||||
let completed = 0
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
|
||||
if (match) {
|
||||
total++
|
||||
if (match[1].toLowerCase() === 'x') completed++
|
||||
}
|
||||
}
|
||||
return { completed, total }
|
||||
}
|
||||
|
||||
function extractTasksFromNotes(notes: Note[]): TaskItem[] {
|
||||
const tasks: TaskItem[] = []
|
||||
for (const note of notes) {
|
||||
const title = note.title?.trim() || 'Sans titre'
|
||||
const lines = (note.content || '').split('\n')
|
||||
lines.forEach((line, idx) => {
|
||||
const match = line.match(/^\s*[-*]?\s*\[([ xX])\]\s*(.*)$/)
|
||||
if (match) {
|
||||
tasks.push({
|
||||
id: `${note.id}-${idx}`,
|
||||
noteId: note.id,
|
||||
noteTitle: title,
|
||||
text: match[2].trim(),
|
||||
completed: match[1].toLowerCase() === 'x',
|
||||
lineIndex: idx,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
function getNotebookColor(notebookId: string | null | undefined, name?: string) {
|
||||
const colors = [
|
||||
{ bg: 'bg-[#A47148]/5 dark:bg-[#A47148]/10', border: 'border-[#A47148]/20', text: 'text-[#A47148]' },
|
||||
{ bg: 'bg-emerald-500/5 dark:bg-emerald-500/10', border: 'border-emerald-500/15', text: 'text-emerald-600 dark:text-emerald-400' },
|
||||
{ bg: 'bg-indigo-500/5 dark:bg-indigo-500/10', border: 'border-indigo-500/15', text: 'text-indigo-600 dark:text-indigo-400' },
|
||||
{ bg: 'bg-blue-500/5 dark:bg-blue-500/10', border: 'border-blue-500/15', text: 'text-blue-600 dark:text-blue-400' },
|
||||
{ bg: 'bg-amber-500/5 dark:bg-amber-500/10', border: 'border-amber-500/15', text: 'text-amber-600 dark:text-amber-400' },
|
||||
{ bg: 'bg-rose-500/5 dark:bg-rose-500/10', border: 'border-rose-500/15', text: 'text-rose-600 dark:text-rose-400' },
|
||||
]
|
||||
const key = name || notebookId || ''
|
||||
const idx = Math.abs(key.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)) % colors.length
|
||||
return colors[idx]
|
||||
}
|
||||
|
||||
function NoteLabelsRow({
|
||||
labelNames,
|
||||
allLabels,
|
||||
max = 3,
|
||||
}: {
|
||||
labelNames: string[] | null | undefined
|
||||
allLabels: { name: string; type?: 'ai' | 'user' }[]
|
||||
max?: number
|
||||
}) {
|
||||
if (!labelNames?.length) return null
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{labelNames.slice(0, max).map((labelName) => {
|
||||
const def = allLabels.find((l) => l.name === labelName)
|
||||
return (
|
||||
<LabelBadge key={labelName} label={labelName} type={def?.type} variant="default" />
|
||||
)
|
||||
})}
|
||||
{labelNames.length > max && (
|
||||
<span className="text-[8.5px] font-mono text-muted-foreground font-bold shrink-0 bg-muted/50 px-1 py-0.5 rounded">
|
||||
+{labelNames.length - max}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteGridIllustrationButton({
|
||||
busy,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
busy: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
|
||||
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
|
||||
className={cn(
|
||||
'absolute bottom-2 end-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteGridThumbnail({
|
||||
note,
|
||||
aiIllustrationEnabled,
|
||||
onNoteIllustrationGenerated,
|
||||
}: {
|
||||
note: Note
|
||||
aiIllustrationEnabled?: boolean
|
||||
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const img = getNoteFeedImage(note)
|
||||
|
||||
const handleGenerateSvg = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!aiIllustrationEnabled || busy || img) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await generateNoteIllustrationSvg(note.id, { skipRevalidation: true })
|
||||
if (!res.ok) {
|
||||
toast.error(res.error)
|
||||
} else {
|
||||
toast.success(t('notes.illustrationGenerated') || 'Illustration générée')
|
||||
await onNoteIllustrationGenerated?.(note.id)
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100'
|
||||
|
||||
if (img) {
|
||||
return (
|
||||
<img
|
||||
src={img}
|
||||
alt=""
|
||||
className="w-full h-full object-cover mix-blend-multiply dark:mix-blend-normal opacity-85 grayscale contrast-115 group-hover/card:scale-105 group-hover/card:grayscale-0 group-hover/card:opacity-100 transition-all duration-500"
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (note.illustrationSvg) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full overflow-hidden bg-[#F5F0E8] dark:bg-muted/30"
|
||||
dangerouslySetInnerHTML={{ __html: prepareNoteIllustrationForGrid(note.illustrationSvg) }}
|
||||
aria-hidden
|
||||
/>
|
||||
{aiIllustrationEnabled && (
|
||||
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted/40 text-muted-foreground/50">
|
||||
<FileText size={28} strokeWidth={1.25} />
|
||||
</div>
|
||||
{aiIllustrationEnabled && (
|
||||
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type { NoteCollectionActions } from '@/lib/note-change-sync'
|
||||
|
||||
type NotesListViewsProps = {
|
||||
notes: Note[]
|
||||
pinnedNotes?: Note[]
|
||||
viewType: NotesViewType
|
||||
layoutMode: NotesLayoutMode
|
||||
onOpen: (note: Note, readOnly?: boolean) => void
|
||||
onOpenHistory?: (note: Note) => void
|
||||
notebookName?: string
|
||||
onGridReorder?: (orderedIds: string[]) => void | Promise<void>
|
||||
} & Partial<NoteCollectionActions>
|
||||
|
||||
export function NotesListViews({
|
||||
notes,
|
||||
pinnedNotes = [],
|
||||
viewType,
|
||||
layoutMode,
|
||||
onOpen,
|
||||
onOpenHistory,
|
||||
notebookName,
|
||||
onTogglePin,
|
||||
onDeleteNote,
|
||||
onArchiveNote,
|
||||
onMoveToNotebook,
|
||||
onNotePatch,
|
||||
onNoteIllustrationGenerated,
|
||||
onGridReorder,
|
||||
}: NotesListViewsProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
const { notebooks } = useNotebooks()
|
||||
const { data: allLabels = [] } = useLabelsQuery()
|
||||
const [, startTransition] = useTransition()
|
||||
const [sortColumn, setSortColumn] = useState<'title' | 'notebook' | 'tasks' | 'modified' | null>(null)
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null)
|
||||
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user?.id) {
|
||||
setAiIllustrationEnabled(false)
|
||||
return
|
||||
}
|
||||
getAISettings(session.user.id)
|
||||
.then((s) => setAiIllustrationEnabled(s.paragraphRefactor !== false))
|
||||
.catch(() => setAiIllustrationEnabled(false))
|
||||
}, [session?.user?.id])
|
||||
|
||||
const untitled = t('notes.untitled')
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
|
||||
const allDisplayNotes = useMemo(() => {
|
||||
const unpinned = notes.filter((n) => !n.isPinned)
|
||||
return [...pinnedNotes, ...unpinned]
|
||||
}, [notes, pinnedNotes])
|
||||
|
||||
const extractTasks = useMemo(() => extractTasksFromNotes(allDisplayNotes), [allDisplayNotes])
|
||||
const completedTasksCount = extractTasks.filter((task) => task.completed).length
|
||||
|
||||
const handleToggleTask = (task: TaskItem) => {
|
||||
const note = allDisplayNotes.find((n) => n.id === task.noteId)
|
||||
if (!note) return
|
||||
const lines = (note.content || '').split('\n')
|
||||
const line = lines[task.lineIndex]
|
||||
if (!line) return
|
||||
const nextChar = task.completed ? ' ' : 'x'
|
||||
lines[task.lineIndex] = line.replace(/\[([ xX])\]/, `[${nextChar}]`)
|
||||
startTransition(async () => {
|
||||
await updateNote(note.id, { content: lines.join('\n') }, { skipRevalidation: true })
|
||||
onNotePatch?.(note.id, { content: lines.join('\n') })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSort = (field: 'title' | 'notebook' | 'tasks' | 'modified') => {
|
||||
if (sortColumn !== field) {
|
||||
setSortColumn(field)
|
||||
setSortDirection('asc')
|
||||
} else if (sortDirection === 'asc') {
|
||||
setSortDirection('desc')
|
||||
} else {
|
||||
setSortColumn(null)
|
||||
setSortDirection(null)
|
||||
}
|
||||
}
|
||||
|
||||
const sortedNotes = useMemo(() => {
|
||||
if (!sortColumn || !sortDirection) return allDisplayNotes
|
||||
const copy = [...allDisplayNotes]
|
||||
return copy.sort((a, b) => {
|
||||
let valA: string | number = ''
|
||||
let valB: string | number = ''
|
||||
if (sortColumn === 'title') {
|
||||
valA = getNoteDisplayTitle(a, untitled).toLowerCase()
|
||||
valB = getNoteDisplayTitle(b, untitled).toLowerCase()
|
||||
} else if (sortColumn === 'notebook') {
|
||||
valA = notebooks.find((nb) => nb.id === a.notebookId)?.name?.toLowerCase() || ''
|
||||
valB = notebooks.find((nb) => nb.id === b.notebookId)?.name?.toLowerCase() || ''
|
||||
} else if (sortColumn === 'tasks') {
|
||||
valA = getNoteTasksStats(a.content || '').completed
|
||||
valB = getNoteTasksStats(b.content || '').completed
|
||||
} else {
|
||||
valA = new Date(a.updatedAt).getTime()
|
||||
valB = new Date(b.updatedAt).getTime()
|
||||
}
|
||||
if (valA < valB) return sortDirection === 'asc' ? -1 : 1
|
||||
if (valA > valB) return sortDirection === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
}, [allDisplayNotes, sortColumn, sortDirection, notebooks, untitled])
|
||||
|
||||
const SortIcon = ({ field }: { field: typeof sortColumn }) =>
|
||||
sortColumn === field ? (
|
||||
sortDirection === 'asc' ? <ChevronUp size={12} /> : <ChevronDown size={12} />
|
||||
) : null
|
||||
|
||||
if (viewType === 'tasks') {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-foreground/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[10px] uppercase font-bold tracking-[0.2em] text-muted-foreground">
|
||||
{t('notes.tasksHeader')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono font-bold text-foreground bg-foreground/[0.03] dark:bg-white/5 py-1 px-3 rounded-full">
|
||||
{t('notes.tasksSummary')
|
||||
.replace('{count}', String(extractTasks.length))
|
||||
.replace('{completed}', String(completedTasksCount))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{extractTasks.length > 0 ? (
|
||||
<div className="overflow-hidden border border-border/40 rounded-2xl bg-card/30 shadow-sm">
|
||||
<div className="divide-y divide-foreground/[0.04]">
|
||||
{extractTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-4 flex items-center justify-between gap-4 hover:bg-foreground/[0.01] transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3.5 flex-grow min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleTask(task)}
|
||||
className={cn(
|
||||
'w-5 h-5 rounded-md border flex items-center justify-center transition-all shrink-0',
|
||||
task.completed
|
||||
? 'bg-brand-accent border-brand-accent text-white'
|
||||
: 'border-border hover:border-brand-accent/60 bg-transparent',
|
||||
)}
|
||||
>
|
||||
{task.completed && <span className="text-xs font-bold">✓</span>}
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[13px] font-light leading-relaxed truncate',
|
||||
task.completed && 'line-through text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{task.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[9.5px] uppercase font-mono tracking-wider text-muted-foreground max-w-[140px] truncate">
|
||||
{t('notes.taskFromNote').replace('{title}', task.noteTitle)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const note = allDisplayNotes.find((n) => n.id === task.noteId)
|
||||
if (note) onOpen(note)
|
||||
}}
|
||||
className="p-1.5 rounded-full hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-all"
|
||||
title={t('notes.openSourceNote')}
|
||||
>
|
||||
<Link2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-12 h-12 rounded-full bg-muted/50 flex items-center justify-center border border-border/40">
|
||||
<CheckSquare size={18} className="text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="font-memento-serif text-lg italic text-muted-foreground">{t('notes.tasksEmptyTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-sm leading-relaxed">{t('notes.tasksEmptyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (layoutMode === 'grid') {
|
||||
return (
|
||||
<NotesMasonryGrid
|
||||
pinnedNotes={pinnedNotes}
|
||||
unpinnedNotes={notes.filter((n) => !n.isPinned)}
|
||||
untitled={untitled}
|
||||
allLabels={allLabels}
|
||||
notebooks={notebooks}
|
||||
aiIllustrationEnabled={aiIllustrationEnabled}
|
||||
onOpen={onOpen}
|
||||
onTogglePin={onTogglePin}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
onGridReorder={onGridReorder}
|
||||
pinnedLabel={t('notes.pinned')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (layoutMode === 'table') {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="overflow-x-auto border border-border/40 rounded-2xl bg-card/30 shadow-sm">
|
||||
<table className="w-full text-left border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border/30">
|
||||
<th
|
||||
className="w-[32%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('title')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableTitle')} <SortIcon field="title" />
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="w-[15%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('notebook')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableNotebook')} <SortIcon field="notebook" />
|
||||
</span>
|
||||
</th>
|
||||
<th className="w-[22%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground">
|
||||
{t('notes.tableLabels')}
|
||||
</th>
|
||||
<th
|
||||
className="w-[12%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('tasks')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableTasks')} <SortIcon field="tasks" />
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="w-[13%] px-4 py-3 text-[10px] uppercase tracking-widest font-black text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
onClick={() => handleSort('modified')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t('notes.tableModified')} <SortIcon field="modified" />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-foreground/[0.03]">
|
||||
{sortedNotes.map((note) => {
|
||||
const title = getNoteDisplayTitle(note, untitled)
|
||||
const nb = notebooks.find((n) => n.id === note.notebookId)
|
||||
const nbColor = getNotebookColor(note.notebookId, nb?.name)
|
||||
const stats = getNoteTasksStats(note.content || '')
|
||||
return (
|
||||
<tr
|
||||
key={note.id}
|
||||
onClick={() => onOpen(note)}
|
||||
className="h-11 hover:bg-foreground/[0.02] cursor-pointer transition-colors group"
|
||||
>
|
||||
<td className="px-4 py-2 font-memento-serif text-[13px] font-medium truncate max-w-[280px]">
|
||||
<span className="inline-flex items-center gap-2 truncate group-hover:text-brand-accent transition-colors">
|
||||
{note.isPinned && <Pin size={11} className="text-amber-500 fill-amber-500 shrink-0" />}
|
||||
{title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{nb ? (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block px-2 py-0.5 rounded-full text-[9px] font-bold tracking-wide border truncate max-w-[125px]',
|
||||
nbColor.bg,
|
||||
nbColor.border,
|
||||
nbColor.text,
|
||||
)}
|
||||
>
|
||||
{nb.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-[10px]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={3} />
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-[10.5px] font-bold text-foreground/80">
|
||||
{stats.total > 0 ? (
|
||||
<span className={stats.completed === stats.total ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}>
|
||||
{stats.completed}/{stats.total} <span className="text-[9px] font-sans">✓</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-[10.5px] font-mono text-muted-foreground whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-16">
|
||||
{pinnedNotes.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-2">
|
||||
{t('notes.pinned')}
|
||||
</h2>
|
||||
<NotesEditorialView
|
||||
notes={pinnedNotes}
|
||||
onOpen={onOpen}
|
||||
notebookName={notebookName}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onTogglePin={onTogglePin}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNotePatch={onNotePatch}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{notes.filter((n) => !n.isPinned).length > 0 && (
|
||||
<NotesEditorialView
|
||||
notes={notes.filter((n) => !n.isPinned)}
|
||||
onOpen={onOpen}
|
||||
notebookName={notebookName}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onTogglePin={onTogglePin}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNotePatch={onNotePatch}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatGridCardDate(date: Date | string, language: string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const locale = language === 'fr' ? fr : enUS
|
||||
if (language === 'fa') {
|
||||
return formatAbsoluteDateLocalized(d, language, 'd MMM yyyy', locale)
|
||||
}
|
||||
const month = d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })
|
||||
const day = d.getUTCDate()
|
||||
const year = d.getUTCFullYear()
|
||||
return `${month.toUpperCase()} ${day}, ${year}`
|
||||
}
|
||||
|
||||
type GridCardSharedProps = {
|
||||
note: Note
|
||||
index: number
|
||||
untitled: string
|
||||
allLabels: { name: string; type?: 'ai' | 'user' }[]
|
||||
notebooks: { id: string; name: string }[]
|
||||
aiIllustrationEnabled?: boolean
|
||||
onOpen: (note: Note) => void
|
||||
onTogglePin?: (note: Note) => void | Promise<void>
|
||||
onDeleteNote?: (note: Note) => void | Promise<void>
|
||||
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
|
||||
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
|
||||
isOverlay?: boolean
|
||||
}
|
||||
|
||||
function NotesMasonryGrid({
|
||||
pinnedNotes,
|
||||
unpinnedNotes,
|
||||
pinnedLabel,
|
||||
onGridReorder,
|
||||
...cardProps
|
||||
}: {
|
||||
pinnedNotes: Note[]
|
||||
unpinnedNotes: Note[]
|
||||
pinnedLabel: string
|
||||
onGridReorder?: (orderedIds: string[]) => void | Promise<void>
|
||||
} & Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'>) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
const displayNotes = useMemo(
|
||||
() => [...pinnedNotes, ...unpinnedNotes],
|
||||
[pinnedNotes, unpinnedNotes],
|
||||
)
|
||||
|
||||
const activeNote = useMemo(
|
||||
() => displayNotes.find((n) => n.id === activeId) ?? null,
|
||||
[displayNotes, activeId],
|
||||
)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }),
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
if (!over || active.id === over.id || !onGridReorder) return
|
||||
|
||||
const activeIdx = displayNotes.findIndex((n) => n.id === active.id)
|
||||
const overIdx = displayNotes.findIndex((n) => n.id === over.id)
|
||||
if (activeIdx === -1 || overIdx === -1) return
|
||||
|
||||
const activeNoteItem = displayNotes[activeIdx]
|
||||
const overNoteItem = displayNotes[overIdx]
|
||||
if (activeNoteItem.isPinned !== overNoteItem.isPinned) return
|
||||
|
||||
const reordered = arrayMove(displayNotes, activeIdx, overIdx)
|
||||
onGridReorder(reordered.map((n) => n.id))
|
||||
},
|
||||
[displayNotes, onGridReorder],
|
||||
)
|
||||
|
||||
const sortEnabled = Boolean(onGridReorder)
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id="notes-grid-masonry"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
{pinnedNotes.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-1">
|
||||
{pinnedLabel}
|
||||
</h2>
|
||||
<NotesGridSection
|
||||
notes={pinnedNotes}
|
||||
sortEnabled={sortEnabled}
|
||||
{...cardProps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{unpinnedNotes.length > 0 && (
|
||||
<NotesGridSection
|
||||
notes={unpinnedNotes}
|
||||
sortEnabled={sortEnabled}
|
||||
indexOffset={pinnedNotes.length}
|
||||
{...cardProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease' }}>
|
||||
{activeNote ? (
|
||||
<div className="cursor-grabbing shadow-2xl rounded-2xl opacity-95">
|
||||
<GridCard note={activeNote} index={0} isOverlay {...cardProps} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
function NotesGridSection({
|
||||
notes,
|
||||
sortEnabled,
|
||||
indexOffset = 0,
|
||||
untitled,
|
||||
allLabels,
|
||||
notebooks,
|
||||
aiIllustrationEnabled,
|
||||
onOpen,
|
||||
onTogglePin,
|
||||
onDeleteNote,
|
||||
onMoveToNotebook,
|
||||
onNoteIllustrationGenerated,
|
||||
className,
|
||||
}: Omit<GridCardSharedProps, 'note' | 'index' | 'isOverlay'> & {
|
||||
notes: Note[]
|
||||
sortEnabled?: boolean
|
||||
indexOffset?: number
|
||||
className?: string
|
||||
}) {
|
||||
const ids = useMemo(() => notes.map((n) => n.id), [notes])
|
||||
|
||||
const grid = (
|
||||
<div className={cn('grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6', className)}>
|
||||
{notes.map((note, index) =>
|
||||
sortEnabled ? (
|
||||
<SortableGridCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
index={indexOffset + index}
|
||||
untitled={untitled}
|
||||
allLabels={allLabels}
|
||||
notebooks={notebooks}
|
||||
aiIllustrationEnabled={aiIllustrationEnabled}
|
||||
onOpen={onOpen}
|
||||
onTogglePin={onTogglePin}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
/>
|
||||
) : (
|
||||
<GridCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
index={indexOffset + index}
|
||||
untitled={untitled}
|
||||
allLabels={allLabels}
|
||||
notebooks={notebooks}
|
||||
aiIllustrationEnabled={aiIllustrationEnabled}
|
||||
onOpen={onOpen}
|
||||
onTogglePin={onTogglePin}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onMoveToNotebook={onMoveToNotebook}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!sortEnabled) return grid
|
||||
|
||||
return (
|
||||
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||
{grid}
|
||||
</SortableContext>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableGridCard(props: GridCardSharedProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.note.id,
|
||||
})
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
'touch-none cursor-grab active:cursor-grabbing',
|
||||
isDragging && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<GridCard {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GridCard({
|
||||
note,
|
||||
index,
|
||||
untitled,
|
||||
allLabels,
|
||||
notebooks,
|
||||
aiIllustrationEnabled,
|
||||
onOpen,
|
||||
onTogglePin,
|
||||
onDeleteNote,
|
||||
onMoveToNotebook,
|
||||
onNoteIllustrationGenerated,
|
||||
isOverlay = false,
|
||||
}: GridCardSharedProps) {
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const title = getNoteDisplayTitle(note, untitled)
|
||||
const excerpt = getNotePlainExcerpt(note, 110)
|
||||
const stats = getNoteTasksStats(note.content || '')
|
||||
const formattedDate = formatGridCardDate(note.updatedAt, language)
|
||||
|
||||
const handlePinClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onTogglePin?.(note)
|
||||
}
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDeleteNote?.(note)
|
||||
}
|
||||
|
||||
const handleBrainstormClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const seed = `${title}\n\n${excerpt}`.trim() || title
|
||||
router.push(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={isOverlay ? false : { opacity: 0, y: 15 }}
|
||||
animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={isOverlay ? undefined : { delay: 0.04 * index, duration: 0.5 }}
|
||||
onClick={() => onOpen(note)}
|
||||
className="bg-card/60 border border-border/40 rounded-2xl overflow-hidden hover:shadow-md hover:border-brand-accent/30 transition-all duration-300 group/card cursor-pointer flex flex-col relative"
|
||||
>
|
||||
<div className="aspect-[16/10] bg-muted/30 border-b border-border/20 overflow-hidden relative">
|
||||
<NoteGridThumbnail
|
||||
note={note}
|
||||
aiIllustrationEnabled={aiIllustrationEnabled}
|
||||
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
|
||||
/>
|
||||
{note.isPinned && (
|
||||
<div className="absolute top-3 start-3 bg-background/90 backdrop-blur-sm p-1.5 rounded-full shadow-sm border border-border/40 text-amber-500">
|
||||
<Pin size={11} className="fill-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
{stats.total > 0 && (
|
||||
<div className="absolute top-3 end-3 bg-background/90 backdrop-blur-sm py-1 px-2.5 rounded-full shadow-sm border border-border/40 text-[9.5px] font-mono font-bold text-muted-foreground">
|
||||
{stats.completed}/{stats.total} ✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-2.5">
|
||||
<NoteLabelsRow labelNames={note.labels} allLabels={allLabels} max={2} />
|
||||
<h3 className="font-memento-serif text-base font-semibold text-foreground leading-snug line-clamp-2 group-hover/card:text-brand-accent transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{excerpt && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 font-light">{excerpt}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-foreground/[0.03] dark:border-white/[0.03] text-[9.5px] text-muted-foreground font-medium uppercase tracking-wider">
|
||||
<span>{formattedDate}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/card:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBrainstormClick}
|
||||
className="p-1 px-2 rounded-full hover:bg-brand-accent/10 text-brand-accent transition-all flex items-center gap-1 cursor-pointer"
|
||||
title={t('notes.brainstormThisIdea') || 'Brainstormer cette idée'}
|
||||
aria-label={t('notes.brainstormThisIdeaAria') || t('notes.brainstormThisIdea') || 'Brainstormer cette idée'}
|
||||
>
|
||||
<Wind size={11} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePinClick}
|
||||
className="p-1 px-2 rounded-full hover:bg-muted text-foreground transition-all cursor-pointer"
|
||||
title={note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
|
||||
aria-label={note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
|
||||
>
|
||||
<Pin size={11} className={note.isPinned ? 'fill-amber-500 text-amber-500' : ''} />
|
||||
</button>
|
||||
{onMoveToNotebook && (
|
||||
<MoveToNotebookPicker
|
||||
notebooks={notebooks}
|
||||
currentNotebookId={note.notebookId}
|
||||
onSelect={(notebookId) => onMoveToNotebook(note, notebookId)}
|
||||
align="end"
|
||||
preferDropUp
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 px-2 rounded-full hover:bg-muted text-foreground transition-all cursor-pointer"
|
||||
title={t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
|
||||
aria-label={t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
|
||||
>
|
||||
<FolderOpen size={11} />
|
||||
</button>
|
||||
</MoveToNotebookPicker>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteClick}
|
||||
className="p-1 px-2 rounded-full hover:bg-rose-50 dark:hover:bg-rose-500/10 text-rose-500 transition-all cursor-pointer"
|
||||
title={t('notes.delete') || 'Supprimer'}
|
||||
aria-label={t('notes.delete') || 'Supprimer'}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders,
|
||||
import { getPendingBrainstormShares, respondToBrainstormShare } from '@/app/actions/brainstorm'
|
||||
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
|
||||
import { toast } from 'sonner'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
@@ -57,7 +56,6 @@ const C = {
|
||||
}
|
||||
|
||||
export function NotificationPanel() {
|
||||
const { refreshNotes } = useRefresh()
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [requests, setRequests] = useState<ShareRequest[]>([])
|
||||
@@ -113,7 +111,6 @@ export function NotificationPanel() {
|
||||
description: t('collaboration.nowHasAccess', { name: 'Note' }),
|
||||
duration: 3000,
|
||||
})
|
||||
refreshNotes(null)
|
||||
setOpen(false)
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('general.error'))
|
||||
@@ -135,7 +132,6 @@ export function NotificationPanel() {
|
||||
try {
|
||||
await toggleReminderDone(noteId, done)
|
||||
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: done } : r))
|
||||
refreshNotes(null)
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
|
||||
// ── Personas definition ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -85,6 +86,7 @@ interface PersonasPanelProps {
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
|
||||
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
|
||||
const [expanded, setExpanded] = useState<PersonaId | null>(null)
|
||||
@@ -111,6 +113,9 @@ export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
|
||||
|
||||
setLoadingId(personaId)
|
||||
try {
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
const res = await fetch('/api/ai/personas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -7,6 +7,8 @@ import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
|
||||
import { QueryProvider } from '@/components/query-provider'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Translations } from '@/lib/i18n/load-translations'
|
||||
import { AiConsentProvider } from '@/components/legal/ai-consent-provider'
|
||||
import { SearchModalProvider } from '@/context/search-modal-context'
|
||||
|
||||
const RTL_LANGUAGES = ['ar', 'fa']
|
||||
|
||||
@@ -21,18 +23,28 @@ interface ProvidersWrapperProps {
|
||||
children: ReactNode
|
||||
initialLanguage?: string
|
||||
initialTranslations?: Translations
|
||||
initialAiProcessingConsent?: boolean
|
||||
}
|
||||
|
||||
export function ProvidersWrapper({ children, initialLanguage = 'en', initialTranslations }: ProvidersWrapperProps) {
|
||||
export function ProvidersWrapper({
|
||||
children,
|
||||
initialLanguage = 'en',
|
||||
initialTranslations,
|
||||
initialAiProcessingConsent = false,
|
||||
}: ProvidersWrapperProps) {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<NoteRefreshProvider>
|
||||
<NotebooksProvider>
|
||||
<EditorUIProvider>
|
||||
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
||||
<DirWrapper>
|
||||
{children}
|
||||
</DirWrapper>
|
||||
<AiConsentProvider initialPersistentConsent={initialAiProcessingConsent}>
|
||||
<DirWrapper>
|
||||
<SearchModalProvider>
|
||||
{children}
|
||||
</SearchModalProvider>
|
||||
</DirWrapper>
|
||||
</AiConsentProvider>
|
||||
</LanguageProvider>
|
||||
</EditorUIProvider>
|
||||
</NotebooksProvider>
|
||||
|
||||
@@ -23,12 +23,18 @@ import Subscript from '@tiptap/extension-subscript'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import { ChartExtension } from './tiptap-chart-extension'
|
||||
import { ChartSuggestionsDialog } from './chart-suggestions-dialog'
|
||||
import { UniqueIdExtension } from './tiptap-unique-id-extension'
|
||||
import { LiveBlockExtension } from './tiptap-live-block-extension'
|
||||
import { BlockPicker, type BlockSuggestion } from './block-picker'
|
||||
import { NoteLinkPicker, type NoteLinkOption } from './note-link-picker'
|
||||
import { NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { EditorState } from '@tiptap/pm/state'
|
||||
import {
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
|
||||
Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
|
||||
Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon,
|
||||
Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon, Link2,
|
||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||
@@ -39,6 +45,12 @@ import { toast } from 'sonner'
|
||||
|
||||
export interface RichTextEditorHandle {
|
||||
getEditor: () => Editor | null
|
||||
triggerChartSuggestions: () => void
|
||||
insertCitation: (
|
||||
payload: { noteId: string; noteTitle: string; excerpt: string },
|
||||
options?: { atEnd?: boolean }
|
||||
) => boolean
|
||||
insertLiveBlock: (block: BlockSuggestion, options?: { atEnd?: boolean }) => boolean
|
||||
}
|
||||
|
||||
interface RichTextEditorProps {
|
||||
@@ -47,6 +59,11 @@ interface RichTextEditorProps {
|
||||
className?: string
|
||||
placeholder?: string
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
noteId?: string
|
||||
}
|
||||
|
||||
interface RichTextEditorRef {
|
||||
triggerChartSuggestions: () => void
|
||||
}
|
||||
|
||||
type SlashItem = {
|
||||
@@ -147,6 +164,12 @@ const slashCommands: SlashItem[] = [
|
||||
// Handler will be called by SlashCommandMenu
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', icon: Link2, category: 'Basic blocks', shortcut: '/bloc',
|
||||
command: (_e) => {
|
||||
window.dispatchEvent(new CustomEvent('memento-open-block-picker'))
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||
@@ -204,9 +227,108 @@ function useImageInsert() {
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload }, ref) {
|
||||
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId }, ref) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const imageInsert = useImageInsert()
|
||||
const [blockPickerOpen, setBlockPickerOpen] = useState(false)
|
||||
const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false)
|
||||
const [noteLinkQuery, setNoteLinkQuery] = useState('')
|
||||
const noteLinkRangeRef = useRef<{ from: number; to: number } | null>(null)
|
||||
const noteLinkPickerOpenRef = useRef(false)
|
||||
noteLinkPickerOpenRef.current = noteLinkPickerOpen
|
||||
const lastEmittedContent = useRef<string>(content || '')
|
||||
const editorInstanceRef = useRef<Editor | null>(null)
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
|
||||
const emitContentChange = useCallback((html: string) => {
|
||||
lastEmittedContent.current = html
|
||||
onChangeRef.current?.(html)
|
||||
}, [])
|
||||
|
||||
// Listen to the slash-command event to open the BlockPicker
|
||||
useEffect(() => {
|
||||
const openHandler = () => setBlockPickerOpen(true)
|
||||
window.addEventListener('memento-open-block-picker', openHandler)
|
||||
return () => window.removeEventListener('memento-open-block-picker', openHandler)
|
||||
}, [])
|
||||
|
||||
const handleSelectBlockRef = useRef<(block: BlockSuggestion) => void>(() => {})
|
||||
const insertCitationRef = useRef<(payload: { noteId: string; noteTitle: string; excerpt: string }, options?: { atEnd?: boolean }) => boolean>(() => false)
|
||||
|
||||
useEffect(() => {
|
||||
const insertHandler = (event: Event) => {
|
||||
const block = (event as CustomEvent<{ block: BlockSuggestion }>).detail?.block
|
||||
if (block) handleSelectBlockRef.current(block)
|
||||
}
|
||||
const citationHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ noteId: string; noteTitle: string; excerpt: string; atEnd?: boolean }>).detail
|
||||
if (!detail) return
|
||||
insertCitationRef.current(detail, { atEnd: detail.atEnd !== false })
|
||||
}
|
||||
window.addEventListener('memento-insert-live-block', insertHandler)
|
||||
window.addEventListener('memento-insert-citation', citationHandler)
|
||||
return () => {
|
||||
window.removeEventListener('memento-insert-live-block', insertHandler)
|
||||
window.removeEventListener('memento-insert-citation', citationHandler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSelectNoteLink = useCallback((selected: NoteLinkOption) => {
|
||||
const ed = editorInstanceRef.current
|
||||
if (!ed) {
|
||||
setNoteLinkPickerOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
let range = noteLinkRangeRef.current
|
||||
if (!range) {
|
||||
const { from } = ed.state.selection
|
||||
const start = Math.max(0, from - 80)
|
||||
const textBefore = ed.state.doc.textBetween(start, from, '\n', '\0')
|
||||
const match = textBefore.match(/\[\[([^\]]*)$/)
|
||||
if (match) {
|
||||
range = { from: from - match[0].length, to: from }
|
||||
}
|
||||
}
|
||||
if (!range) {
|
||||
setNoteLinkPickerOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
const title = (selected.title || t('documentInfo.network.untitled')).trim()
|
||||
const href = `/home?openNote=${encodeURIComponent(selected.id)}`
|
||||
|
||||
ed.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: range.from, to: range.to })
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text: title,
|
||||
marks: [{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
}],
|
||||
})
|
||||
.insertContent(' ')
|
||||
.run()
|
||||
|
||||
const html = ed.getHTML()
|
||||
emitContentChange(html)
|
||||
setNoteLinkPickerOpen(false)
|
||||
noteLinkRangeRef.current = null
|
||||
|
||||
if (noteId) {
|
||||
window.dispatchEvent(new CustomEvent(NOTE_REQUEST_SAVE_EVENT, {
|
||||
detail: { noteId, reason: 'note-link' },
|
||||
}))
|
||||
}
|
||||
}, [emitContentChange, noteId, t])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@@ -226,42 +348,91 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
Subscript,
|
||||
Typography,
|
||||
ChartExtension,
|
||||
UniqueIdExtension,
|
||||
LiveBlockExtension,
|
||||
Placeholder.configure({ placeholder: placeholder || t('richTextEditor.placeholder') || "Tapez '/' pour voir les commandes..." }),
|
||||
],
|
||||
content: content || '',
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: { class: 'notion-editor' },
|
||||
handlePaste: (view, event, slice) => {
|
||||
if (!onImageUpload) return false;
|
||||
const items = Array.from(event.clipboardData?.items || []);
|
||||
const hasImage = items.some(item => item.type.startsWith('image/'));
|
||||
if (!hasImage) return false;
|
||||
event.preventDefault();
|
||||
const images = items.filter(item => item.type.startsWith('image/')).map(item => item.getAsFile()).filter(f => f !== null) as File[];
|
||||
images.forEach(async (file) => {
|
||||
try {
|
||||
toast.info(t('notes.uploading'));
|
||||
const url = await onImageUpload(file);
|
||||
const { schema } = view.state;
|
||||
const node = schema.nodes.image.create({ src: url });
|
||||
const tr = view.state.tr.replaceSelectionWith(node);
|
||||
view.dispatch(tr);
|
||||
} catch (err) {
|
||||
toast.error(t('notes.uploadFailed'));
|
||||
handleDOMEvents: {
|
||||
click: (_view, event) => {
|
||||
const link = (event.target as HTMLElement).closest('a[href]')
|
||||
if (!link) return false
|
||||
const href = link.getAttribute('href') || ''
|
||||
const noteIdMatch = href.match(/[?&]openNote=([^&#]+)/)
|
||||
if (noteIdMatch) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
window.open(
|
||||
`/home?openNote=${decodeURIComponent(noteIdMatch[1])}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
return true
|
||||
}
|
||||
});
|
||||
return true;
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (!onImageUpload) return false
|
||||
const items = Array.from(event.clipboardData?.items || [])
|
||||
const hasImage = items.some(item => item.type.startsWith('image/'))
|
||||
if (!hasImage) return false
|
||||
event.preventDefault()
|
||||
const imageFiles = items
|
||||
.filter(item => item.type.startsWith('image/'))
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
|
||||
void (async () => {
|
||||
for (const file of imageFiles) {
|
||||
try {
|
||||
toast.info(t('notes.uploading'))
|
||||
const url = await onImageUpload(file)
|
||||
const editorInstance = editorInstanceRef.current
|
||||
if (!editorInstance) continue
|
||||
const inserted = editorInstance.chain().focus().setImage({ src: url }).run()
|
||||
if (inserted) {
|
||||
emitContentChange(editorInstance.getHTML())
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed'))
|
||||
}
|
||||
}
|
||||
})()
|
||||
return true
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
const html = e.getHTML()
|
||||
lastEmittedContent.current = html
|
||||
onChange?.(html)
|
||||
emitContentChange(e.getHTML())
|
||||
if (!e.isEditable) return
|
||||
const { from, empty } = e.state.selection
|
||||
if (!empty) return
|
||||
const start = Math.max(0, from - 80)
|
||||
const textBefore = e.state.doc.textBetween(start, from, '\n', '\0')
|
||||
const match = textBefore.match(/\[\[([^\]]*)$/)
|
||||
if (match) {
|
||||
noteLinkRangeRef.current = { from: from - match[0].length, to: from }
|
||||
setNoteLinkQuery(match[1])
|
||||
setNoteLinkPickerOpen(true)
|
||||
} else if (!noteLinkPickerOpenRef.current) {
|
||||
setNoteLinkPickerOpen(false)
|
||||
noteLinkRangeRef.current = null
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const lastEmittedContent = useRef<string>(content || '')
|
||||
useEffect(() => {
|
||||
editorInstanceRef.current = editor ?? null
|
||||
}, [editor])
|
||||
|
||||
// Chart suggestions dialog state
|
||||
const [chartSuggestionsOpen, setChartSuggestionsOpen] = useState(false)
|
||||
@@ -278,18 +449,83 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
}
|
||||
}, [content, editor])
|
||||
|
||||
useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor])
|
||||
|
||||
// Chart suggestion handlers
|
||||
const handleOpenChartSuggestions = useCallback(() => {
|
||||
const handleOpenChartSuggestions = useCallback(async () => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
|
||||
// Get current selection text if any
|
||||
const { from, to, empty } = editor.state.selection
|
||||
const selectionText = !empty ? editor.state.doc.textBetween(from, to, ' ') : null
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
|
||||
setChartSuggestionsOpen(true)
|
||||
}, [editor])
|
||||
}, [editor, requestAiConsent])
|
||||
|
||||
const insertCitationInEditor = useCallback((
|
||||
payload: { noteId: string; noteTitle: string; excerpt: string },
|
||||
options?: { atEnd?: boolean }
|
||||
) => {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
const plainExcerpt = payload.excerpt.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (!plainExcerpt) return false
|
||||
const chain = editor.chain()
|
||||
if (options?.atEnd !== false) {
|
||||
chain.focus('end')
|
||||
} else {
|
||||
chain.focus()
|
||||
}
|
||||
chain.insertContent([
|
||||
{ type: 'paragraph', content: [] },
|
||||
{
|
||||
type: 'blockquote',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: plainExcerpt }] }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: '— ' },
|
||||
{
|
||||
type: 'text',
|
||||
text: payload.noteTitle,
|
||||
marks: [{ type: 'link', attrs: { href: `/home?openNote=${payload.noteId}`, target: '_blank', rel: 'noopener noreferrer' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]).scrollIntoView().run()
|
||||
emitContentChange(editor.getHTML())
|
||||
return true
|
||||
}, [editor, emitContentChange])
|
||||
|
||||
const insertLiveBlockInEditor = useCallback((block: BlockSuggestion, options?: { atEnd?: boolean }) => {
|
||||
if (!editor || !editor.isEditable) return false
|
||||
const chain = editor.chain()
|
||||
if (options?.atEnd !== false) {
|
||||
chain.focus('end')
|
||||
} else {
|
||||
chain.focus()
|
||||
}
|
||||
chain.insertContent({
|
||||
type: 'liveBlock',
|
||||
attrs: {
|
||||
sourceNoteId: block.noteId,
|
||||
blockId: block.blockId,
|
||||
snapshotContent: block.content,
|
||||
sourceNoteTitle: block.noteTitle,
|
||||
},
|
||||
}).scrollIntoView().run()
|
||||
emitContentChange(editor.getHTML())
|
||||
return true
|
||||
}, [editor, emitContentChange])
|
||||
|
||||
insertCitationRef.current = insertCitationInEditor
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getEditor: () => editor,
|
||||
triggerChartSuggestions: () => {
|
||||
if (editor) {
|
||||
handleOpenChartSuggestions()
|
||||
}
|
||||
},
|
||||
insertCitation: insertCitationInEditor,
|
||||
insertLiveBlock: insertLiveBlockInEditor,
|
||||
}), [editor, handleOpenChartSuggestions, insertCitationInEditor, insertLiveBlockInEditor])
|
||||
|
||||
const handleSelectChart = useCallback((chartContent: string) => {
|
||||
if (!editor || !editor.isEditable) return
|
||||
@@ -297,29 +533,69 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
try {
|
||||
console.log('[handleSelectChart] Inserting chart type:', chartContent.split('\n')[0])
|
||||
|
||||
// Use TipTap's insertContent with proper JSON content
|
||||
// This is the most reliable way to insert custom nodes
|
||||
editor.commands.insertContent([
|
||||
{
|
||||
type: 'chartBlock',
|
||||
attrs: {
|
||||
code: chartContent,
|
||||
language: 'chart'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: []
|
||||
}
|
||||
])
|
||||
// Get current selection
|
||||
const { from, to, empty } = editor.state.selection
|
||||
|
||||
console.log('[handleSelectChart] Chart inserted')
|
||||
// Insert chart AFTER the selected text (not replace it)
|
||||
// First insert a paragraph for spacing, then the chart, then another paragraph
|
||||
editor.chain()
|
||||
.focus()
|
||||
.insertContentAt(to, [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: []
|
||||
},
|
||||
{
|
||||
type: 'chartBlock',
|
||||
attrs: {
|
||||
code: chartContent,
|
||||
language: 'chart'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: []
|
||||
}
|
||||
])
|
||||
.run()
|
||||
|
||||
console.log('[handleSelectChart] Chart inserted after selection')
|
||||
} catch (error) {
|
||||
console.error('[handleSelectChart] Failed:', error)
|
||||
toast.error('Failed to insert chart: ' + (error as Error).message)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
const handleSelectBlock = useCallback(async (block: BlockSuggestion) => {
|
||||
setBlockPickerOpen(false)
|
||||
if (!editor) return
|
||||
|
||||
if (noteId) {
|
||||
try {
|
||||
await fetch('/api/blocks/embed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourceNoteId: block.noteId, blockId: block.blockId, targetNoteId: noteId }),
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'liveBlock',
|
||||
attrs: {
|
||||
sourceNoteId: block.noteId,
|
||||
blockId: block.blockId,
|
||||
snapshotContent: block.content,
|
||||
sourceNoteTitle: block.noteTitle,
|
||||
},
|
||||
}).run()
|
||||
emitContentChange(editor.getHTML())
|
||||
}, [editor, noteId, emitContentChange])
|
||||
|
||||
handleSelectBlockRef.current = handleSelectBlock
|
||||
|
||||
return (
|
||||
<div className={cn('notion-editor-wrapper', className)}>
|
||||
{editor && (
|
||||
@@ -339,7 +615,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
return (from !== to && !e.isActive('codeBlock')) || isImage
|
||||
}}
|
||||
>
|
||||
<BubbleToolbar editor={editor} />
|
||||
<BubbleToolbar editor={editor} onSuggestCharts={handleOpenChartSuggestions} />
|
||||
</BubbleMenu>
|
||||
)}
|
||||
|
||||
@@ -360,6 +636,24 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
||||
onSelectChart={handleSelectChart}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BlockPicker
|
||||
isOpen={blockPickerOpen}
|
||||
onClose={() => setBlockPickerOpen(false)}
|
||||
currentNoteId={noteId}
|
||||
onSelectBlock={handleSelectBlock}
|
||||
/>
|
||||
|
||||
<NoteLinkPicker
|
||||
isOpen={noteLinkPickerOpen}
|
||||
query={noteLinkQuery}
|
||||
currentNoteId={noteId}
|
||||
onClose={() => {
|
||||
setNoteLinkPickerOpen(false)
|
||||
noteLinkRangeRef.current = null
|
||||
}}
|
||||
onSelect={handleSelectNoteLink}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -413,8 +707,9 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
|
||||
)
|
||||
}
|
||||
|
||||
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onSuggestCharts?: () => void }) {
|
||||
const { t, language } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
|
||||
const toastAiError = (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
@@ -460,6 +755,8 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
const { from, to } = editor.state.selection
|
||||
const text = editor.state.doc.textBetween(from, to, ' ')
|
||||
if (!text || text.split(/\s+/).length < 2) return
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
setAiLoading(true)
|
||||
setAiOpen(false)
|
||||
setTranslateOpen(false)
|
||||
@@ -571,6 +868,9 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
</div>
|
||||
)}
|
||||
<button className="notion-ai-subitem" onClick={() => handleAI('explain')}><BookOpen className="w-3.5 h-3.5 text-orange-500" /><span>{t('ai.action.explain')}</span></button>
|
||||
{onSuggestCharts && (
|
||||
<button className="notion-ai-subitem" onClick={() => { setAiOpen(false); onSuggestCharts() }}><BarChart3 className="w-3.5 h-3.5 text-blue-500" /><span>{t('ai.action.generateChart') || 'Générer un graphique'}</span></button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{aiModal && (
|
||||
@@ -607,6 +907,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
|
||||
function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: Editor; onInsertImage: (editor: Editor) => void; onSuggestCharts: () => void }) {
|
||||
const { t } = useLanguage()
|
||||
const { requestAiConsent } = useAiConsent()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
@@ -647,6 +948,16 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...slashCommands[28], title: 'Suggest Charts', description: 'AI suggère des graphiques basés sur votre contenu', categoryId: 'ai' },
|
||||
{ ...slashCommands[29], title: 'Living Block', description: 'Insérer un bloc vivant depuis une autre note', categoryId: 'basic' },
|
||||
{
|
||||
title: t('richTextEditor.slashNoteLink'),
|
||||
description: t('richTextEditor.slashNoteLinkDesc'),
|
||||
icon: Link2,
|
||||
categoryId: 'basic' as SlashCategoryId,
|
||||
command: (e) => {
|
||||
e.chain().focus().insertContent('[[').run()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
@@ -673,6 +984,8 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
|
||||
} else if (item.isAi && item.aiOption) {
|
||||
deleteSlashText(); closeMenu(); setAiLoading(true)
|
||||
try {
|
||||
const consented = await requestAiConsent()
|
||||
if (!consented) return
|
||||
const allText = editor.state.doc.textContent
|
||||
if (!allText || allText.split(/\s+/).length < 5) return
|
||||
const result = await aiReformulate(allText, item.aiOption)
|
||||
|
||||
651
memento-note/components/search-modal.tsx
Normal file
651
memento-note/components/search-modal.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import {
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
CornerDownRight,
|
||||
Folder,
|
||||
HelpCircle,
|
||||
Command,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import type { Note } from '@/lib/types'
|
||||
|
||||
interface SearchMatch {
|
||||
id: string
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
path: string
|
||||
type: 'document' | 'heading' | 'paragraph' | 'list'
|
||||
headingLevel?: number
|
||||
text: string
|
||||
matchedText: string
|
||||
lineIndex: number
|
||||
}
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Strip HTML tags and decode basic entities for plain-text matching */
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Safe RegExp escape */
|
||||
function escapeRegExp(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
const router = useRouter()
|
||||
const { notebooks } = useNotebooks()
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [useRegex, setUseRegex] = useState(false)
|
||||
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||
const [searchInTrash, setSearchInTrash] = useState(false)
|
||||
const [savedQueries, setSavedQueries] = useState<string[]>([])
|
||||
const [results, setResults] = useState<Note[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Load saved queries from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('momento-search-saved')
|
||||
if (stored) setSavedQueries(JSON.parse(stored))
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
// Focus input when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Rebuild notebook path helper
|
||||
const getNotebookPath = useCallback(
|
||||
(notebookId: string | null | undefined): string => {
|
||||
if (!notebookId) return ''
|
||||
const segments: string[] = []
|
||||
let current = notebooks.find(n => n.id === notebookId)
|
||||
while (current) {
|
||||
segments.unshift(current.name)
|
||||
const parentId = current.parentId
|
||||
current = parentId ? notebooks.find(n => n.id === parentId) : undefined
|
||||
}
|
||||
return segments.join(' / ')
|
||||
},
|
||||
[notebooks]
|
||||
)
|
||||
|
||||
// Fetch notes from API with debounce
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
|
||||
if (!query.trim()) {
|
||||
setResults([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
search: query.trim(),
|
||||
limit: '40',
|
||||
...(searchInTrash ? {} : {}),
|
||||
})
|
||||
const res = await fetch(`/api/notes?${params}`)
|
||||
if (!res.ok) throw new Error('search failed')
|
||||
const data = await res.json()
|
||||
setResults(data.data ?? [])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, 280)
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [query, searchInTrash])
|
||||
|
||||
// Reset selected index when results change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [results, query])
|
||||
|
||||
// Build SearchMatch list from results for the left panel
|
||||
const filteredMatches = useMemo((): SearchMatch[] => {
|
||||
if (!query.trim() || results.length === 0) return []
|
||||
|
||||
const searchRegex = (() => {
|
||||
try {
|
||||
// BUG FIX: caseSensitive → 'g' (respect casse), insensitive → 'gi'
|
||||
const flag = caseSensitive ? 'g' : 'gi'
|
||||
const pattern = useRegex ? query : escapeRegExp(query)
|
||||
return new RegExp(pattern, flag)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (!searchRegex) return []
|
||||
|
||||
const matches: SearchMatch[] = []
|
||||
|
||||
results.forEach(note => {
|
||||
const notebookPath = getNotebookPath(note.notebookId)
|
||||
const fullPath = notebookPath
|
||||
? `${notebookPath} / ${note.title ?? 'Sans titre'}`
|
||||
: (note.title ?? 'Sans titre')
|
||||
|
||||
// 1. Title match
|
||||
if (note.title && searchRegex.test(note.title)) {
|
||||
matches.push({
|
||||
id: `${note.id}-title`,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title ?? 'Sans titre',
|
||||
path: fullPath,
|
||||
type: 'document',
|
||||
text: note.title ?? '',
|
||||
matchedText: note.title ?? '',
|
||||
lineIndex: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Content match — strip HTML, split by lines
|
||||
if (note.content) {
|
||||
const plainContent = stripHtml(note.content)
|
||||
const lines = plainContent.split(/\n|(?<=\.)\s+(?=[A-Z])/)
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.length < 10) return
|
||||
|
||||
// Reset lastIndex for global regex
|
||||
searchRegex.lastIndex = 0
|
||||
if (!searchRegex.test(trimmed)) return
|
||||
|
||||
let type: 'heading' | 'paragraph' | 'list' = 'paragraph'
|
||||
let headingLevel: number | undefined
|
||||
let displayVal = trimmed.slice(0, 120)
|
||||
|
||||
if (/^#{1,6}\s/.test(trimmed)) {
|
||||
type = 'heading'
|
||||
const m = trimmed.match(/^(#{1,6})\s+(.+)$/)
|
||||
if (m) { headingLevel = m[1].length; displayVal = m[2] }
|
||||
} else if (/^[-*+]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) {
|
||||
type = 'list'
|
||||
displayVal = trimmed.replace(/^[-*+\d.]+\s+/, '').slice(0, 120)
|
||||
}
|
||||
|
||||
matches.push({
|
||||
id: `${note.id}-line-${index}`,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title ?? 'Sans titre',
|
||||
path: fullPath,
|
||||
type,
|
||||
headingLevel,
|
||||
text: trimmed,
|
||||
matchedText: displayVal,
|
||||
lineIndex: index,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return matches.slice(0, 200) // cap pour les perf
|
||||
}, [results, query, useRegex, caseSensitive, getNotebookPath])
|
||||
|
||||
// Active match for preview panel
|
||||
const activeMatch = filteredMatches[selectedIndex]
|
||||
|
||||
// Count distinct notes in results
|
||||
const docMatchesCount = useMemo(() => {
|
||||
return new Set(filteredMatches.map(m => m.noteId)).size
|
||||
}, [filteredMatches])
|
||||
|
||||
// Preview panel: highlighted context around the matched line
|
||||
const highlightedPreview = useMemo(() => {
|
||||
if (!activeMatch) return null
|
||||
const currentNote = results.find(n => n.id === activeMatch.noteId)
|
||||
if (!currentNote) return null
|
||||
if (!query.trim()) return <p className="text-xs text-concrete p-2">{stripHtml(currentNote.content).slice(0, 500)}</p>
|
||||
|
||||
try {
|
||||
// Fixed flag for preview highlights
|
||||
const flag = caseSensitive ? 'g' : 'gi'
|
||||
const searchPattern = useRegex ? query : escapeRegExp(query)
|
||||
const highlightRegex = new RegExp(`(${searchPattern})`, flag)
|
||||
|
||||
const plainContent = stripHtml(currentNote.content)
|
||||
const lines = plainContent.split(/\n|(?<=\.)\s+(?=[A-Z])/).filter(l => l.trim())
|
||||
|
||||
const targetIndex = activeMatch.lineIndex >= 0 ? activeMatch.lineIndex : 0
|
||||
const startLine = Math.max(0, targetIndex - 2)
|
||||
const endLine = Math.min(lines.length - 1, targetIndex + 6)
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5 my-1">
|
||||
{startLine > 0 && (
|
||||
<div className="text-[10px] text-concrete/40 italic pl-10 py-0.5">…</div>
|
||||
)}
|
||||
{lines.slice(startLine, endLine + 1).map((line, idx) => {
|
||||
const absoluteIdx = startLine + idx
|
||||
const isMatchLine = absoluteIdx === targetIndex
|
||||
highlightRegex.lastIndex = 0
|
||||
const hasMatch = highlightRegex.test(line)
|
||||
highlightRegex.lastIndex = 0
|
||||
const segments = line.split(highlightRegex)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={absoluteIdx}
|
||||
className={`py-1 px-2 rounded-lg text-xs leading-relaxed flex items-start gap-3 transition-colors ${
|
||||
isMatchLine
|
||||
? 'bg-blueprint/5 border-l-2 border-blueprint pl-2 dark:bg-blueprint/10'
|
||||
: 'opacity-70'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-[9px] text-concrete/40 text-right w-6 select-none mt-0.5 shrink-0">
|
||||
{absoluteIdx + 1}
|
||||
</span>
|
||||
<span className="font-sans text-ink dark:text-dark-ink break-words min-w-0">
|
||||
{hasMatch
|
||||
? segments.map((seg, sIdx) => {
|
||||
highlightRegex.lastIndex = 0
|
||||
const isMatch = highlightRegex.test(seg)
|
||||
return isMatch ? (
|
||||
<mark
|
||||
key={sIdx}
|
||||
className="bg-blueprint/20 text-ink dark:text-white dark:bg-blueprint/30 rounded px-0.5 border-b border-blueprint font-semibold"
|
||||
>
|
||||
{seg}
|
||||
</mark>
|
||||
) : (
|
||||
seg
|
||||
)
|
||||
})
|
||||
: line}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{endLine < lines.length - 1 && (
|
||||
<div className="text-[10px] text-concrete/40 italic pl-10 py-0.5">…</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
} catch {
|
||||
return <p className="text-xs text-concrete p-2">{stripHtml(currentNote.content).slice(0, 600)}</p>
|
||||
}
|
||||
}, [activeMatch, results, query, useRegex, caseSensitive])
|
||||
|
||||
// Row highlight renderer
|
||||
const renderHighlightedRow = (text: string) => {
|
||||
if (!query.trim()) return <span className="truncate">{text}</span>
|
||||
try {
|
||||
// Fixed: caseSensitive → 'g', insensitive → 'gi'
|
||||
const flag = caseSensitive ? 'g' : 'gi'
|
||||
const pattern = useRegex ? query : escapeRegExp(query)
|
||||
const regex = new RegExp(`(${pattern})`, flag)
|
||||
const segments = text.split(regex)
|
||||
return (
|
||||
<span className="truncate">
|
||||
{segments.map((seg, i) => {
|
||||
regex.lastIndex = 0
|
||||
return regex.test(seg) ? (
|
||||
<mark key={i} className="bg-blueprint/25 text-ink dark:text-white dark:bg-blueprint/40 px-0.5 rounded font-bold">
|
||||
{seg}
|
||||
</mark>
|
||||
) : (
|
||||
seg
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
} catch {
|
||||
return <span className="truncate">{text}</span>
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, filteredMatches.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)) }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (filteredMatches[selectedIndex]) {
|
||||
router.push(`/home?openNote=${filteredMatches[selectedIndex].noteId}`)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, selectedIndex, filteredMatches, onClose, router])
|
||||
|
||||
// Save/remove query from localStorage
|
||||
const handleSaveQuery = () => {
|
||||
if (!query.trim()) return
|
||||
setSavedQueries(prev => {
|
||||
const next = prev.includes(query.trim()) ? prev : [...prev.slice(-9), query.trim()]
|
||||
try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
const handleRemoveQuery = () => {
|
||||
setSavedQueries(prev => {
|
||||
const next = prev.filter(q => q !== query.trim())
|
||||
try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 backdrop-blur-sm flex items-center justify-center z-[200] p-4 sm:p-6 select-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: 8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full max-w-[860px] h-[580px] sm:h-[640px] rounded-2xl bg-white dark:bg-[#121212] border border-border dark:border-zinc-800 shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Search bar */}
|
||||
<div className="p-4 border-b border-border/60 dark:border-zinc-800 bg-paper/50 dark:bg-[#161616] flex flex-col gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2.5 relative">
|
||||
<Search size={17} className="text-concrete absolute left-3 top-1/2 -translate-y-1/2 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Rechercher dans toutes vos notes…"
|
||||
className="w-full text-sm pl-10 pr-28 py-2.5 rounded-xl border border-border/70 dark:border-zinc-800 bg-white/85 dark:bg-[#1C1C1C] text-ink dark:text-dark-ink placeholder-concrete/50 outline-none focus:border-blueprint transition-colors"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCaseSensitive(c => !c)}
|
||||
title="Respecter la casse"
|
||||
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md transition-colors select-none ${
|
||||
caseSensitive ? 'text-blueprint bg-blueprint/8' : 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUseRegex(r => !r)}
|
||||
title="Mode regex"
|
||||
className={`px-1.5 py-1 text-[9.5px] font-bold rounded-md transition-colors select-none ${
|
||||
useRegex ? 'text-blueprint bg-blueprint/8' : 'text-concrete hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<button onClick={onClose} className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-md text-concrete transition-all">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved queries */}
|
||||
{savedQueries.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-[10px] text-concrete">
|
||||
<span className="uppercase text-[9px] font-bold">Favoris :</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{savedQueries.map(sq => (
|
||||
<button
|
||||
key={sq}
|
||||
onClick={() => setQuery(sq)}
|
||||
className={`px-2 py-0.5 rounded-md border text-[9.5px] font-medium transition-all hover:border-blueprint ${
|
||||
query === sq
|
||||
? 'bg-blueprint/10 border-blueprint text-blueprint'
|
||||
: 'bg-white dark:bg-zinc-800 border-border/40 text-concrete'
|
||||
}`}
|
||||
>
|
||||
{sq}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="px-4 py-2 bg-[#F8F7F4] dark:bg-[#141414] border-b border-border/40 dark:border-zinc-800 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-0.5 border border-border/40 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-lg p-0.5">
|
||||
<button
|
||||
disabled={filteredMatches.length === 0}
|
||||
onClick={() => setSelectedIndex(p => Math.max(0, p - 1))}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[9.5px] font-bold font-mono px-1.5 text-concrete">
|
||||
{filteredMatches.length > 0 ? `${selectedIndex + 1}/${filteredMatches.length}` : '—'}
|
||||
</span>
|
||||
<button
|
||||
disabled={filteredMatches.length === 0}
|
||||
onClick={() => setSelectedIndex(p => Math.min(filteredMatches.length - 1, p + 1))}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-concrete disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-concrete">
|
||||
{isLoading
|
||||
? 'Recherche en cours…'
|
||||
: filteredMatches.length > 0
|
||||
? `${filteredMatches.length} occurrence${filteredMatches.length > 1 ? 's' : ''} dans ${docMatchesCount} note${docMatchesCount > 1 ? 's' : ''}`
|
||||
: query.trim()
|
||||
? 'Aucun résultat'
|
||||
: 'Tapez pour rechercher'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{query.trim() && (
|
||||
<button
|
||||
onClick={savedQueries.includes(query.trim()) ? handleRemoveQuery : handleSaveQuery}
|
||||
className="text-[10px] font-bold uppercase tracking-wider text-blueprint border-b border-dashed border-blueprint hover:border-solid"
|
||||
>
|
||||
{savedQueries.includes(query.trim()) ? 'Retirer favori' : 'Sauvegarder'}
|
||||
</button>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-[10.5px] font-medium text-concrete">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchInTrash}
|
||||
onChange={e => setSearchInTrash(e.target.checked)}
|
||||
className="rounded border-border/60 text-blueprint focus:ring-blueprint w-3 h-3"
|
||||
/>
|
||||
<span>Corbeille incluse</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dual panel */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left — results list */}
|
||||
<div className="w-[45%] h-full border-r border-border/40 dark:border-zinc-800 flex flex-col bg-[#FAF9F5]/30 dark:bg-[#121212]/30 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{filteredMatches.map((m, idx) => {
|
||||
const isSelected = idx === selectedIndex
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setSelectedIndex(idx)}
|
||||
onDoubleClick={() => {
|
||||
router.push(`/home?openNote=${m.noteId}`)
|
||||
onClose()
|
||||
}}
|
||||
className={`p-2.5 rounded-xl cursor-pointer text-left select-none relative flex flex-col gap-1 border transition-all ${
|
||||
isSelected
|
||||
? 'bg-white dark:bg-zinc-800 shadow-sm border-blueprint/25'
|
||||
: 'border-transparent hover:bg-black/[0.02] dark:hover:bg-white/[0.02]'
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-3.5 bg-blueprint rounded-r-full" />
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{m.type === 'document' && <FileText size={12} className="text-sky-500 shrink-0" />}
|
||||
{m.type === 'heading' && (
|
||||
<span className="text-[8px] font-extrabold uppercase bg-indigo-50 dark:bg-indigo-950/40 text-indigo-500 border border-indigo-200 dark:border-indigo-900 px-1 rounded-sm shrink-0 font-mono">
|
||||
H{m.headingLevel ?? ''}
|
||||
</span>
|
||||
)}
|
||||
{m.type === 'list' && (
|
||||
<span className="text-[8px] font-extrabold uppercase bg-emerald-50 dark:bg-emerald-950/40 text-emerald-600 border border-emerald-200 dark:border-emerald-900 px-1 rounded-sm shrink-0 font-mono">
|
||||
LIST
|
||||
</span>
|
||||
)}
|
||||
{m.type === 'paragraph' && (
|
||||
<span className="text-[8px] font-extrabold uppercase bg-zinc-100 dark:bg-zinc-800 text-concrete border border-border/20 px-1 rounded-sm shrink-0 font-mono">
|
||||
TXT
|
||||
</span>
|
||||
)}
|
||||
<span className={`font-semibold truncate text-xs ${isSelected ? 'text-ink dark:text-dark-ink' : 'text-muted-foreground'}`}>
|
||||
{m.noteTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-concrete truncate pl-5 font-sans leading-tight">
|
||||
{renderHighlightedRow(m.matchedText)}
|
||||
</div>
|
||||
<div className="text-[8.5px] font-mono tracking-widest uppercase text-concrete/45 truncate pl-5 mt-0.5">
|
||||
{m.path}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isLoading && filteredMatches.length === 0 && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center p-6 text-concrete pt-24 space-y-2">
|
||||
<Search size={20} className="opacity-30 animate-pulse" />
|
||||
<p className="text-[11px] font-medium italic opacity-70">
|
||||
{query.trim() ? 'Aucune note ne correspond.' : 'Tapez pour obtenir des résultats instantanés.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="h-full flex items-center justify-center text-concrete pt-24">
|
||||
<div className="w-4 h-4 border-2 border-blueprint/30 border-t-blueprint rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — preview panel */}
|
||||
<div className="flex-1 h-full bg-[#FCFCFA]/80 dark:bg-[#151515] flex flex-col overflow-hidden">
|
||||
{activeMatch ? (
|
||||
<div className="flex-1 flex flex-col p-5 overflow-hidden">
|
||||
<div className="space-y-3 overflow-hidden flex flex-col flex-1">
|
||||
<div className="flex items-center gap-1.5 p-2 bg-black/[0.02] dark:bg-white/[0.02] border border-border/40 rounded-xl">
|
||||
<Folder size={11} className="text-concrete shrink-0" />
|
||||
<span className="text-[9.5px] font-mono tracking-widest text-concrete font-medium uppercase truncate">
|
||||
{activeMatch.path}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border/40 dark:border-zinc-800 pb-2">
|
||||
<h4 className="text-[13px] font-serif font-bold text-ink dark:text-dark-ink">
|
||||
{activeMatch.noteTitle}
|
||||
</h4>
|
||||
<p className="text-[8px] uppercase tracking-wider text-concrete font-bold mt-0.5">
|
||||
APERÇU CONTEXTUEL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-[#121212] border border-border/30 dark:border-zinc-800 rounded-xl p-3.5 shadow-inner min-h-0">
|
||||
{highlightedPreview}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border/40 dark:border-zinc-800 flex items-center justify-between shrink-0 mt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push(`/home?openNote=${activeMatch.noteId}`)
|
||||
onClose()
|
||||
}}
|
||||
className="px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
|
||||
>
|
||||
<CornerDownRight size={13} />
|
||||
<span>Ouvrir dans l'éditeur</span>
|
||||
</button>
|
||||
<span className="text-[10px] text-concrete font-bold font-mono bg-paper dark:bg-white/5 border border-border/30 px-2 py-1 rounded">
|
||||
ID: {activeMatch.noteId.slice(0, 8)}…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-6 text-concrete space-y-3">
|
||||
<HelpCircle size={24} className="opacity-25" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11.5px] font-bold">Aperçu du document</p>
|
||||
<p className="text-[10px] italic opacity-60">
|
||||
Sélectionnez un résultat pour explorer son contenu.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer keyboard hints */}
|
||||
<div className="p-3 bg-[#FAF9F5] dark:bg-[#0E0E0E] border-t border-border/50 dark:border-zinc-800 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-5 text-[9.5px] font-bold text-concrete/70">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">↑↓</kbd>
|
||||
naviguer
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">Entrée</kbd>
|
||||
ouvrir
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="bg-slate-200 dark:bg-zinc-800 px-1 py-0.5 rounded text-ink dark:text-white text-[9px]">Échap</kbd>
|
||||
fermer
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-wider text-concrete/50">
|
||||
<Command size={10} />
|
||||
<span>Momento Search</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,11 +30,19 @@ import {
|
||||
Sparkles,
|
||||
Home,
|
||||
Network,
|
||||
Search,
|
||||
GraduationCap,
|
||||
Scissors,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from 'lucide-react'
|
||||
import { useSearchModal } from '@/context/search-modal-context'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
||||
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
|
||||
import { NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Notebook, Note } from '@/lib/types'
|
||||
import { toast } from 'sonner'
|
||||
@@ -50,20 +58,21 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { performSignOut } from '@/lib/auth-client'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
import { UsageMeter } from './usage-meter'
|
||||
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms'
|
||||
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
|
||||
function NoteLink({
|
||||
title,
|
||||
isActive,
|
||||
isPinned,
|
||||
onClick,
|
||||
}: {
|
||||
title: string
|
||||
isActive: boolean
|
||||
isPinned?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { language } = useLanguage()
|
||||
@@ -74,15 +83,18 @@ function NoteLink({
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 ps-12 pe-4 py-2 text-[12px] transition-colors rounded-lg text-start',
|
||||
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
|
||||
'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start',
|
||||
isActive
|
||||
? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-1.5 h-1.5 rounded-full shrink-0',
|
||||
isActive ? 'bg-foreground' : 'bg-transparent border border-muted-foreground/30'
|
||||
)} />
|
||||
<span className="break-words line-clamp-2 leading-tight">{title}</span>
|
||||
<FileText
|
||||
size={12}
|
||||
className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')}
|
||||
/>
|
||||
<span className="truncate flex-1">{title}</span>
|
||||
{isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />}
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
@@ -187,7 +199,7 @@ function SidebarCarnetItem({
|
||||
}: {
|
||||
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
||||
isActive: boolean
|
||||
notes: { id: string; title: string }[]
|
||||
notes: { id: string; title: string; isPinned?: boolean }[]
|
||||
activeNoteId: string | null
|
||||
onCarnetClick: () => void
|
||||
onNoteClick: (noteId: string, carnetId: string) => void
|
||||
@@ -207,7 +219,7 @@ function SidebarCarnetItem({
|
||||
}) {
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const hasChildren = hasChildNotebooks || React.Children.count(children) > 0
|
||||
const hasChildren = hasChildNotebooks || React.Children.count(children) > 0 || notes.length > 0
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
// Close context menu on outside click
|
||||
@@ -274,12 +286,10 @@ function SidebarCarnetItem({
|
||||
/>
|
||||
)}
|
||||
<div className={cn(
|
||||
'w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border shrink-0 transition-all',
|
||||
isActive
|
||||
? 'bg-brand-accent text-white border-brand-accent'
|
||||
: 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
|
||||
'w-5 h-5 flex items-center justify-center shrink-0 transition-colors',
|
||||
isActive ? 'text-brand-accent' : 'text-muted-foreground/80',
|
||||
)}>
|
||||
{carnet.initial}
|
||||
{isExpanded ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-start flex items-center gap-2 min-w-0">
|
||||
@@ -395,17 +405,18 @@ function SidebarCarnetItem({
|
||||
|
||||
<div className="space-y-0.5 py-1">
|
||||
{children}
|
||||
{isActive && notes.map(note => (
|
||||
{isExpanded && notes.map(note => (
|
||||
<NoteLink
|
||||
key={note.id}
|
||||
title={note.title}
|
||||
isPinned={note.isPinned}
|
||||
isActive={activeNoteId === note.id}
|
||||
onClick={() => onNoteClick(note.id, carnet.id)}
|
||||
/>
|
||||
))}
|
||||
{isActive && notes.length === 0 && !hasChildren && (
|
||||
<p className="ps-8 py-2 text-[10px] italic text-muted-foreground/40 font-light">
|
||||
{t('common.noResults')}
|
||||
{isExpanded && notes.length === 0 && !hasChildren && (
|
||||
<p className="ps-6 py-1 text-[9px] italic text-muted-foreground/40 font-light">
|
||||
{t('sidebar.notebookEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -423,8 +434,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const router = useRouter()
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
const { notebooks, trashNotebook, updateNotebookOrderOptimistic, moveNotebookToParent } = useNotebooks()
|
||||
const { refreshKey } = useNoteRefresh()
|
||||
const { notebooks, trashNotebook, updateNotebookOrderOptimistic, moveNotebookToParent, refreshNotebooks } = useNotebooks()
|
||||
const { open: openSearch } = useSearchModal()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [createParentId, setCreateParentId] = useState<string | null>(null)
|
||||
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
|
||||
@@ -460,7 +471,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
||||
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string }[]>>({})
|
||||
const [notebookNotes, setNotebookNotes] = useState<Record<string, { id: string; title: string; isPinned?: boolean }[]>>({})
|
||||
const [activeView, setActiveView] = useState<NavigationView>('notebooks')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('newest')
|
||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||
@@ -487,6 +498,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const currentNotebookId = searchParams.get('notebook')
|
||||
const currentNoteId = searchParams.get('openNote')
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentNotebookId) return
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
let id: string | null | undefined = currentNotebookId
|
||||
while (id) {
|
||||
next.add(id)
|
||||
const nb = notebooks.find(n => n.id === id)
|
||||
id = nb?.parentId ?? null
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [currentNotebookId, notebooks])
|
||||
|
||||
const isInboxActive =
|
||||
pathname === '/home' &&
|
||||
!searchParams.get('notebook') &&
|
||||
@@ -523,7 +548,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
let cancelled = false
|
||||
getTrashCount().then(count => { if (!cancelled) setTrashCount(count) })
|
||||
return () => { cancelled = true }
|
||||
}, [refreshKey])
|
||||
}, [])
|
||||
|
||||
const notebookIdsKey = useMemo(() => notebooks.map(nb => nb.id).sort().join(','), [notebooks])
|
||||
|
||||
@@ -537,6 +562,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const mapped = notes.map((n: Note) => ({
|
||||
id: n.id,
|
||||
title: getNoteDisplayTitle(n, t('notes.untitled')),
|
||||
isPinned: n.isPinned,
|
||||
}))
|
||||
return [nb.id, mapped] as const
|
||||
})
|
||||
@@ -546,8 +572,57 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
}
|
||||
load()
|
||||
return () => { cancelled = true }
|
||||
// refreshKey: reload note titles whenever any note is saved/created/deleted
|
||||
}, [notebookIdsKey, refreshKey, t])
|
||||
}, [notebookIdsKey, t])
|
||||
|
||||
useEffect(() => {
|
||||
const onNoteChange = (e: Event) => {
|
||||
const detail = (e as CustomEvent<NoteChangeEvent>).detail
|
||||
if (detail.type === 'deleted') {
|
||||
setNotebookNotes((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const key of Object.keys(next)) {
|
||||
next[key] = next[key].filter((n) => n.id !== detail.noteId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setTrashCount((count) => count + 1)
|
||||
return
|
||||
}
|
||||
if (detail.type === 'created' && detail.note.notebookId) {
|
||||
const nbId = detail.note.notebookId
|
||||
const title = getNoteDisplayTitle(detail.note, t('notes.untitled'))
|
||||
setNotebookNotes((prev) => {
|
||||
const list = prev[nbId] || []
|
||||
if (list.some((n) => n.id === detail.note.id)) return prev
|
||||
return {
|
||||
...prev,
|
||||
[nbId]: [{ id: detail.note.id, title, isPinned: detail.note.isPinned }, ...list],
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (detail.type === 'updated') {
|
||||
const note = detail.note
|
||||
const title = getNoteDisplayTitle(note, t('notes.untitled'))
|
||||
setNotebookNotes((prev) => {
|
||||
const next: Record<string, { id: string; title: string; isPinned?: boolean }[]> = {}
|
||||
for (const [key, list] of Object.entries(prev)) {
|
||||
const filtered = list.filter((n) => n.id !== note.id)
|
||||
if (filtered.length > 0) next[key] = filtered
|
||||
}
|
||||
if (note.notebookId) {
|
||||
next[note.notebookId] = [
|
||||
{ id: note.id, title, isPinned: note.isPinned },
|
||||
...(next[note.notebookId] || []),
|
||||
]
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||
return () => window.removeEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||
}, [t])
|
||||
|
||||
const handleCarnetClick = (notebookId: string) => {
|
||||
const params = new URLSearchParams()
|
||||
@@ -684,13 +759,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
if (!res.ok) throw new Error('Rename failed')
|
||||
setRenamingNotebook(null)
|
||||
setRenameValue('')
|
||||
router.refresh()
|
||||
await refreshNotebooks()
|
||||
} catch (err) {
|
||||
console.error('Rename failed:', err)
|
||||
} finally {
|
||||
setIsRenaming(false)
|
||||
}
|
||||
}, [renamingNotebook, renameValue, router])
|
||||
}, [renamingNotebook, renameValue, refreshNotebooks])
|
||||
|
||||
const getDescendantIds = useCallback((notebookId: string): string[] => {
|
||||
const ids: string[] = []
|
||||
@@ -834,26 +909,26 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
className={cn(
|
||||
// Mobile: fixed overlay, slide in/out
|
||||
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
|
||||
'h-full min-h-0 w-72 lg:w-80 shrink-0 flex flex-col',
|
||||
'h-full min-h-0 w-72 lg:w-80 shrink-0 flex flex-row overflow-hidden',
|
||||
'transition-transform duration-300 ease-in-out',
|
||||
isMobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0',
|
||||
'border-e border-border/40 bg-white/95 md:bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* ── Top: Logo + Icons + View Toggle ── */}
|
||||
<div className="p-6 mb-8 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */}
|
||||
<div className="w-[54px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-4 shrink-0 select-none">
|
||||
|
||||
{/* Top : Logo + navigation */}
|
||||
<div className="flex flex-col items-center gap-3 w-full">
|
||||
{/* Logo avec dropdown profil */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center gap-2 group/logo cursor-pointer">
|
||||
<div className="w-10 h-10 bg-brand-accent flex items-center justify-center rounded-xl shadow-lg shadow-brand-accent/10 rotate-3 group-hover/logo:rotate-0 transition-all duration-500">
|
||||
<span className="text-white font-serif text-xl font-bold">M</span>
|
||||
</div>
|
||||
<span className="text-lg font-serif font-bold tracking-tight text-ink dark:text-paper">Memento</span>
|
||||
<div className="w-9 h-9 bg-brand-accent hover:rotate-6 active:scale-95 flex items-center justify-center rounded-xl shadow-md transition-all cursor-pointer mb-1">
|
||||
<span className="text-white font-serif font-bold text-sm">M</span>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52 bg-popover border-border">
|
||||
<DropdownMenuContent align="start" className="w-52 bg-popover border-border ms-2">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/profile" className="flex items-center gap-2 cursor-pointer">
|
||||
<User className="h-4 w-4" />
|
||||
@@ -879,70 +954,129 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
'p-1.5 transition-all rounded-lg border flex items-center justify-center',
|
||||
pathname.startsWith('/settings')
|
||||
? 'bg-brand-accent text-white border-brand-accent shadow-lg shadow-brand-accent/20'
|
||||
: 'text-muted-ink hover:text-ink hover:bg-white/50 dark:hover:bg-white/10 border-transparent hover:border-border'
|
||||
)}
|
||||
title={t('nav.settings')}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { router.push('/home') }}
|
||||
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
||||
title={t('nav.home')}
|
||||
>
|
||||
<Home size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
||||
>
|
||||
{isDark ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
<NotificationPanel />
|
||||
{/* Boutons de navigation principaux */}
|
||||
<div className="flex flex-col gap-1.5 w-full px-1.5">
|
||||
{([
|
||||
{ id: 'notebooks', icon: BookOpen, label: t('nav.notebooks'), onClick: () => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }, isActive: activeView === 'notebooks' && !pathname.startsWith('/settings') },
|
||||
{ id: 'graph', icon: Network, label: 'Vue graphe', onClick: () => router.push('/graph'), isActive: pathname === '/graph' },
|
||||
{ id: 'revision', icon: GraduationCap, label: 'Révisions', onClick: () => setActiveView('revision'), isActive: activeView === 'revision' },
|
||||
{ id: 'agents', icon: Bot, label: t('agents.intelligenceOS') || 'Intelligence IA', onClick: () => { setActiveView('agents'); router.push('/agents') }, isActive: activeView === 'agents' || (pathname.startsWith('/agents') && activeView !== 'notebooks') },
|
||||
{ id: 'reminders', icon: Bell, label: t('sidebar.reminders'), onClick: () => setActiveView('reminders'), isActive: activeView === 'reminders' },
|
||||
] as { id: string; icon: React.FC<{ size?: number }>; label: string; onClick: () => void; isActive: boolean }[]).map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
||||
item.isActive
|
||||
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
|
||||
: 'text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
|
||||
)}
|
||||
>
|
||||
{item.isActive && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />
|
||||
)}
|
||||
<item.icon size={16} />
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-xl border border-border dark:border-white/10">
|
||||
<button
|
||||
onClick={() => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'notebooks' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('nav.notebooks')}
|
||||
{/* Bottom : utilitaires */}
|
||||
<div className="flex flex-col gap-1.5 w-full px-1.5 items-center">
|
||||
<NotificationPanel />
|
||||
|
||||
<Link
|
||||
href="/trash"
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
||||
pathname === '/trash'
|
||||
? 'bg-rose-500/10 text-rose-500 border border-rose-500/25'
|
||||
: 'text-concrete hover:text-rose-500 hover:bg-rose-500/5'
|
||||
)}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
{pathname === '/trash' && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-rose-500 rounded-r-full" />}
|
||||
<Trash2 size={16} />
|
||||
{trashCount > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 bg-rose-500 rounded-full border-2 border-[#FAF9F5] dark:border-[#0E0E0E]" />
|
||||
)}
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{t('sidebar.trash')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/home?shared=1&forceList=1"
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
||||
searchParams.get('shared') === '1' && pathname === '/home'
|
||||
? 'bg-sky-500/10 text-sky-500 border border-sky-500/25'
|
||||
: 'text-concrete hover:text-sky-500 hover:bg-sky-500/5'
|
||||
)}
|
||||
>
|
||||
<Users size={16} />
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{t('sidebar.sharedWithMe')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={openSearch}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
|
||||
title="Ctrl+K"
|
||||
>
|
||||
<Search size={15} />
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
Recherche (Ctrl+K)
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveView('reminders')}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'reminders' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('sidebar.reminders')}
|
||||
onClick={toggleTheme}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-all relative group"
|
||||
>
|
||||
<Clock size={14} />
|
||||
{isDark ? <Sun size={15} /> : <Moon size={15} />}
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{isDark ? 'Mode clair' : 'Mode sombre'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'agents' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('nav.agents')}
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
||||
pathname.startsWith('/settings')
|
||||
? 'bg-brand-accent/10 text-brand-accent border border-brand-accent/25'
|
||||
: 'text-concrete hover:text-ink dark:hover:text-white hover:bg-black/[0.04] dark:hover:bg-white/[0.04]'
|
||||
)}
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
{pathname.startsWith('/settings') && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-brand-accent rounded-r-full" />}
|
||||
<Settings size={15} />
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{t('nav.settings')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => { setActiveView('brainstorms'); router.push('/brainstorm') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'brainstorms' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('brainstorm.sessions')}
|
||||
onClick={() => performSignOut('/login')}
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-concrete hover:text-red-500 hover:bg-rose-500/5 transition-all relative group"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<LogOut size={14} />
|
||||
<span className="absolute left-[50px] top-1/2 -translate-y-1/2 bg-ink dark:bg-white dark:text-ink text-paper text-[9px] font-bold py-1 px-2 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 pointer-events-none shadow-md uppercase tracking-wider">
|
||||
{t('sidebar.signOut')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Column 2 : Panneau de contenu dynamique ── */}
|
||||
<div className="flex-1 h-full flex flex-col overflow-hidden bg-[#FCFCFA] dark:bg-[#111111]">
|
||||
|
||||
{/* ── Scrollable content ── */}
|
||||
<div className="flex-1 overflow-y-auto space-y-6 -mx-2 px-2 custom-scrollbar pb-4">
|
||||
<div className="flex-1 overflow-y-auto space-y-6 -mx-0 custom-scrollbar pb-4">
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{activeView === 'notebooks' ? (
|
||||
@@ -1123,71 +1257,37 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<SidebarBrainstorms />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ── Vue Révisions (placeholder en attendant US-FLASHCARDS) ── */}
|
||||
{activeView === 'revision' && (
|
||||
<motion.div
|
||||
key="revision"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="px-4"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-4">
|
||||
<GraduationCap size={13} className="text-brand-accent" />
|
||||
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">Révisions</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center text-center p-6 border border-dashed border-border/50 rounded-2xl bg-paper/20 space-y-3">
|
||||
<GraduationCap size={24} className="text-concrete/40" />
|
||||
<p className="text-[11px] font-medium text-concrete/70">Flashcards bientôt disponibles</p>
|
||||
<p className="text-[10px] text-concrete/50">Les decks de révision IA (SM-2) arrivent dans la prochaine itération.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="pt-4 border-t border-border/40 mt-auto pb-4 space-y-4">
|
||||
{/* ── Usage meter en bas du panneau ── */}
|
||||
<div className="border-t border-border/20 px-3 py-3 mt-auto">
|
||||
<UsageMeter />
|
||||
<div className="px-2 space-y-0.5">
|
||||
<Link
|
||||
href="/home?shared=1&forceList=1"
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
||||
searchParams.get('shared') === '1' && pathname === '/home'
|
||||
? 'bg-accent/5 text-accent'
|
||||
: 'text-muted-ink hover:text-ink hover:bg-black/5'
|
||||
)}
|
||||
>
|
||||
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/home' ? 'text-accent' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span>{t('sidebar.sharedWithMe')}</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/archive"
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl"
|
||||
>
|
||||
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
|
||||
<span>{t('sidebar.archive')}</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/trash"
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
||||
pathname === '/trash'
|
||||
? 'bg-rose-50 text-rose-500'
|
||||
: 'text-muted-ink hover:text-rose-500 hover:bg-rose-50/50'
|
||||
)}
|
||||
>
|
||||
<Trash2 size={14} className={pathname === '/trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
|
||||
<span>{t('sidebar.trash')}</span>
|
||||
{trashCount > 0 && (
|
||||
<span className="ms-auto w-1.5 h-1.5 rounded-full bg-rose-400" />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* ── Intelligence section ── */}
|
||||
<div className="pt-3 border-t border-border/20 mx-2 mt-1 space-y-0.5">
|
||||
<p className="text-[9px] font-bold text-muted-ink tracking-[0.2em] uppercase px-1 mb-1 opacity-60">Intelligence</p>
|
||||
<Link
|
||||
href="/graph"
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
||||
pathname === '/graph'
|
||||
? 'bg-indigo-500/10 text-indigo-500'
|
||||
: 'text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5'
|
||||
)}
|
||||
>
|
||||
<Network
|
||||
size={14}
|
||||
className={pathname === '/graph' ? 'text-indigo-500' : 'text-muted-ink group-hover:text-indigo-500'}
|
||||
/>
|
||||
<span className="flex-1">Vue en graphe</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{/* fin colonne 2 */}
|
||||
</aside>
|
||||
|
||||
<CreateNotebookDialog
|
||||
|
||||
186
memento-note/components/tiptap-live-block-extension.tsx
Normal file
186
memento-note/components/tiptap-live-block-extension.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Zap, AlertCircle, Unlink, ArrowRight } from 'lucide-react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LiveBlock Node View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LiveBlockViewProps {
|
||||
node: {
|
||||
attrs: {
|
||||
sourceNoteId: string
|
||||
blockId: string
|
||||
snapshotContent: string
|
||||
sourceNoteTitle: string
|
||||
}
|
||||
}
|
||||
updateAttributes: (attrs: Record<string, string>) => void
|
||||
deleteNode: () => void
|
||||
}
|
||||
|
||||
function LiveBlockView({ node, updateAttributes, deleteNode }: LiveBlockViewProps) {
|
||||
const { sourceNoteId, blockId, snapshotContent, sourceNoteTitle } = node.attrs
|
||||
const [localContent, setLocalContent] = useState(snapshotContent || '')
|
||||
const [isDeleted, setIsDeleted] = useState(false)
|
||||
const [isOffline, setIsOffline] = useState(false)
|
||||
const [pulse, setPulse] = useState(false)
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Fetch current block status on mount
|
||||
useEffect(() => {
|
||||
if (!sourceNoteId || !blockId) return
|
||||
fetch(`/api/blocks/${encodeURIComponent(blockId)}/status?sourceNoteId=${sourceNoteId}`)
|
||||
.then(r => r.json())
|
||||
.then((data: { exists: boolean; content: string; sourceNoteTitle: string }) => {
|
||||
if (!data.exists) {
|
||||
setIsDeleted(true)
|
||||
} else {
|
||||
setLocalContent(data.content)
|
||||
updateAttributes({ snapshotContent: data.content, sourceNoteTitle: data.sourceNoteTitle })
|
||||
}
|
||||
})
|
||||
.catch(() => setIsOffline(true))
|
||||
}, [sourceNoteId, blockId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Listen for real-time block update events
|
||||
useEffect(() => {
|
||||
const handleBlockUpdate = (e: CustomEvent) => {
|
||||
if (e.detail?.blockId !== blockId) return
|
||||
setLocalContent(e.detail.content)
|
||||
updateAttributes({ snapshotContent: e.detail.content })
|
||||
setPulse(true)
|
||||
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current)
|
||||
pulseTimerRef.current = setTimeout(() => setPulse(false), 1200)
|
||||
}
|
||||
const handleBlockDeleted = (e: CustomEvent) => {
|
||||
if (e.detail?.blockId !== blockId) return
|
||||
setIsDeleted(true)
|
||||
}
|
||||
|
||||
window.addEventListener('live-block:update', handleBlockUpdate as EventListener)
|
||||
window.addEventListener('live-block:deleted', handleBlockDeleted as EventListener)
|
||||
return () => {
|
||||
window.removeEventListener('live-block:update', handleBlockUpdate as EventListener)
|
||||
window.removeEventListener('live-block:deleted', handleBlockDeleted as EventListener)
|
||||
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current)
|
||||
}
|
||||
}, [blockId, updateAttributes])
|
||||
|
||||
const handleDetach = useCallback(async () => {
|
||||
// Convert this node to a plain paragraph with snapshot text
|
||||
deleteNode()
|
||||
}, [deleteNode])
|
||||
|
||||
const handleOpenSource = useCallback(() => {
|
||||
window.open(`/home?openNote=${encodeURIComponent(sourceNoteId)}`, '_blank', 'noopener,noreferrer')
|
||||
}, [sourceNoteId])
|
||||
|
||||
const borderClass = isDeleted
|
||||
? 'border-l-rose-500 border-y-rose-200 border-r-rose-200 bg-rose-50/20 dark:border-l-red-700 dark:border-y-red-900/40 dark:border-r-red-900/40 dark:bg-red-950/5'
|
||||
: isOffline
|
||||
? 'border-l-amber-500 border-y-amber-200 border-r-amber-200 bg-amber-50/10 dark:border-l-amber-600 dark:border-y-amber-800/40 dark:border-r-amber-800/40'
|
||||
: pulse
|
||||
? 'border-l-blue-500 border-y-blue-300 border-r-blue-300 bg-blue-50/20 shadow-md shadow-blue-500/15 dark:bg-blue-950/10'
|
||||
: 'border-l-blue-500 border-y-[#E8E6E3] border-r-[#E8E6E3] bg-blue-50/5 dark:border-y-zinc-800 dark:border-r-zinc-800 dark:bg-blue-950/5'
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="group/liveblock my-4 not-prose">
|
||||
<div className={`w-full rounded-xl border-l-[3px] border-y border-r transition-all duration-300 overflow-hidden ${borderClass}`}>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-1.5 flex items-center justify-between bg-black/[0.015] dark:bg-white/[0.01] border-b border-black/[0.03] dark:border-white/[0.02]">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<AlertCircle size={10} className="text-rose-500 shrink-0" />
|
||||
) : (
|
||||
<Zap size={10} className={`shrink-0 ${isOffline ? 'text-amber-500' : 'text-blue-500 fill-blue-500/20'}`} />
|
||||
)}
|
||||
<span className="text-[10px] font-sans font-medium text-[var(--color-concrete)] hover:text-[var(--color-ink)] transition-colors cursor-default max-w-[200px] truncate">
|
||||
{isDeleted ? 'Source déconnectée' : (sourceNoteTitle || 'Note connectée')}
|
||||
</span>
|
||||
{isDeleted ? (
|
||||
<span className="bg-rose-500/10 text-rose-600 dark:text-rose-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
||||
DÉCONNECTÉ
|
||||
</span>
|
||||
) : isOffline ? (
|
||||
<span className="bg-amber-500/10 text-amber-600 dark:text-amber-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider">
|
||||
HORS-LIGNE
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-blue-500/10 text-blue-600 dark:text-blue-400 font-bold px-1.5 rounded text-[8px] uppercase tracking-wider animate-pulse">
|
||||
LIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isDeleted ? (
|
||||
<button
|
||||
onClick={handleDetach}
|
||||
className="text-[9.5px] font-bold text-rose-600 hover:text-rose-500 dark:text-rose-400 flex items-center gap-1 hover:underline transition-all"
|
||||
contentEditable={false}
|
||||
>
|
||||
<Unlink size={10} />
|
||||
Décharger le lien
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleOpenSource}
|
||||
className="opacity-0 group-hover/liveblock:opacity-100 flex items-center gap-1 text-[9.5px] font-extrabold text-blue-600 dark:text-blue-400 hover:underline transition-all"
|
||||
contentEditable={false}
|
||||
>
|
||||
Ouvrir <ArrowRight size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="px-4 py-3 bg-blue-500/[0.015] dark:bg-blue-500/[0.005]">
|
||||
<p
|
||||
className="text-sm leading-relaxed text-[var(--color-ink)] opacity-80 dark:text-[var(--color-dark-ink)] font-sans whitespace-pre-wrap"
|
||||
contentEditable={false}
|
||||
>
|
||||
{localContent || '(bloc vide)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TipTap Node Definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const LiveBlockExtension = Node.create({
|
||||
name: 'liveBlock',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
sourceNoteId: { default: '' },
|
||||
blockId: { default: '' },
|
||||
snapshotContent: { default: '' },
|
||||
sourceNoteTitle: { default: '' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'div[data-live-block]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(HTMLAttributes, { 'data-live-block': 'true' })]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(LiveBlockView)
|
||||
},
|
||||
})
|
||||
65
memento-note/components/tiptap-unique-id-extension.ts
Normal file
65
memento-note/components/tiptap-unique-id-extension.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
const BLOCK_TYPES = ['paragraph', 'heading', 'blockquote', 'bulletList', 'orderedList', 'taskList', 'codeBlock']
|
||||
|
||||
function generateBlockId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
export const UniqueIdExtension = Extension.create({
|
||||
name: 'uniqueId',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('uniqueId'),
|
||||
appendTransaction(transactions, _oldState, newState) {
|
||||
const hasDocChanged = transactions.some(t => t.docChanged)
|
||||
if (!hasDocChanged) return null
|
||||
|
||||
const { tr, doc } = newState
|
||||
let modified = false
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!BLOCK_TYPES.includes(node.type.name)) return
|
||||
if (node.attrs['data-id']) return
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
'data-id': generateBlockId(),
|
||||
})
|
||||
modified = true
|
||||
})
|
||||
|
||||
return modified ? tr : null
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: BLOCK_TYPES,
|
||||
attributes: {
|
||||
'data-id': {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes['data-id']) return {}
|
||||
return { 'data-id': attributes['data-id'] }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -50,23 +50,43 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const getPackLabel = () => {
|
||||
if (!data.tier) return t('usageMeter.packName');
|
||||
switch (data.tier) {
|
||||
case 'PRO': return t('usageMeter.packPro');
|
||||
case 'BUSINESS': return t('usageMeter.packBusiness');
|
||||
case 'ENTERPRISE': return t('usageMeter.packEnterprise');
|
||||
default: return t('usageMeter.packName');
|
||||
}
|
||||
};
|
||||
|
||||
const isProPlus = data.tier && data.tier !== 'BASIC';
|
||||
|
||||
// Features visibles dans l'UI (les features techniques comme expand/enrich sont cachées)
|
||||
const VISIBLE_FEATURES = new Set([
|
||||
'semantic_search',
|
||||
'auto_tag',
|
||||
'auto_title',
|
||||
'reformulate',
|
||||
'chat',
|
||||
'brainstorm_create', // Sera affiché comme "Sessions brainstorm"
|
||||
'suggest_charts',
|
||||
]);
|
||||
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch'),
|
||||
auto_tag: t('usageMeter.featureTags'),
|
||||
auto_title: t('usageMeter.featureTitles'),
|
||||
reformulate: t('usageMeter.featureReformulate'),
|
||||
chat: t('usageMeter.featureChat'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate'),
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormSessions'), // Label simplifié
|
||||
suggest_charts: t('usageMeter.featureCharts'),
|
||||
};
|
||||
|
||||
const featureQuotas = Object.entries(data.quotas)
|
||||
.filter(([_, q]) => q.limit > 0)
|
||||
.filter(([key, q]) => q.limit > 0 && VISIBLE_FEATURES.has(key))
|
||||
.map(([key, quota]) => ({
|
||||
key,
|
||||
key: key === 'brainstorm_create' ? 'brainstorm_sessions' : key, // Renommer pour l'affichage
|
||||
label: featureLabels[key] || key,
|
||||
used: quota.used,
|
||||
limit: quota.limit,
|
||||
@@ -105,7 +125,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
: 'text-brand-accent fill-brand-accent/20'
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] font-bold text-ink/70">{t('usageMeter.packName')}</span>
|
||||
<span className="text-[11px] font-bold text-ink/70">{getPackLabel()}</span>
|
||||
|
||||
{!isUnlimited && (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user