fix(audit): nettoyage accès, garde-fous build, unification quotas
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m59s
CI / Deploy production (on server) (push) Successful in 24s

Semaine 1 — fuites et accès :
- /archive rendu accessible via sidebar (était invisible)
- Suppression pages orphelines : /chat, /graph, /support, test-title-suggestions
- Fixes i18n : search-modal (clé non interpolée), sidebar tooltips, export PDF
- 15 locales mises à jour

Semaine 2 — garde-fous :
- 8 erreurs TS fixees → ignoreBuildErrors: false (build type-safe)
- reactStrictMode: true (dev-only, zero impact prod)
- reserveUsageOrThrow → wrapper deprecie vers reserveAiUsageOrThrow
- auth-guard.ts : requireAuthOrResponse() + requireAdminOrResponse()

Tests 212/212 | Lint 0 erreur | Build OK | tsc 0 erreur
This commit is contained in:
Antigravity
2026-07-17 18:47:44 +00:00
parent e8ee53c815
commit 88a7d2ad0a
42 changed files with 216 additions and 2081 deletions

View File

@@ -1,222 +0,0 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { ChatSidebar } from './chat-sidebar'
import { ChatMessages } from './chat-messages'
import { ChatInput } from './chat-input'
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
import { toast } from 'sonner'
import type { UIMessage } from 'ai'
import { useLanguage } from '@/lib/i18n'
interface ChatContainerProps {
initialConversations: any[]
notebooks: any[]
webSearchAvailable?: boolean
}
export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
const { t, language } = useLanguage()
const [conversations, setConversations] = useState(initialConversations)
const [currentId, setCurrentId] = useState<string | null>(null)
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
// Prevents the useEffect from loading an empty conversation
// when we just created one via createConversation()
const skipHistoryLoad = useRef(false)
const scrollRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({
api: '/api/chat',
})).current
const {
messages,
sendMessage,
status,
setMessages,
stop,
} = useChat({
transport,
onError: (error) => {
toast.error(error.message || t('chat.assistantError'))
},
})
const isLoading = status === 'submitted' || status === 'streaming'
const refreshConversations = useCallback(async () => {
try {
const updated = await getConversations()
setConversations(updated)
} catch {}
}, [])
// Timeout warning: show toast if response takes > 30s
useEffect(() => {
if (!isLoading) return
const timer = setTimeout(() => {
toast.warning(t('chat.timeoutWarning') || 'Response is taking longer than expected...')
}, 30000)
return () => clearTimeout(timer)
}, [isLoading, t])
// Sync historyMessages after each completed streaming response
// so the display doesn't revert to stale history.
// Also refresh sidebar so new conversation appears.
useEffect(() => {
if (status === 'ready' && messages.length > 0) {
setHistoryMessages([...messages])
refreshConversations()
}
}, [status, messages, refreshConversations])
// Load conversation details when the user selects a different conversation
useEffect(() => {
// Skip if we just created the conversation — useChat already has the messages
if (skipHistoryLoad.current) {
skipHistoryLoad.current = false
return
}
if (currentId) {
const loadMessages = async () => {
setIsLoadingHistory(true)
try {
const details = await getConversationDetails(currentId)
if (details) {
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
id: m.id || `hist-${i}`,
role: m.role as 'user' | 'assistant',
parts: [{ type: 'text' as const, text: m.content }],
}))
setHistoryMessages(loaded)
setMessages(loaded)
}
} catch (error) {
toast.error(t('chat.loadError'))
} finally {
setIsLoadingHistory(false)
}
}
loadMessages()
} else {
setMessages([])
setHistoryMessages([])
}
}, [currentId])
const handleSendMessage = async (content: string, notebookId?: string) => {
if (notebookId) {
setSelectedNotebook(notebookId)
}
// If no active conversation, create one BEFORE streaming
let convId = currentId
if (!convId) {
try {
const result = await createConversation(content, notebookId || selectedNotebook)
convId = result.id
// Tell the useEffect to skip — we don't want to load an empty conversation
skipHistoryLoad.current = true
setCurrentId(convId)
setHistoryMessages([])
setConversations((prev) => [
{ id: result.id, title: result.title, updatedAt: new Date() },
...prev,
])
} catch {
toast.error(t('chat.createError'))
return
}
}
try {
await sendMessage(
{ text: content },
{
body: {
conversationId: convId,
notebookId: notebookId || selectedNotebook || undefined,
language,
webSearch: webSearchEnabled,
},
}
)
} catch (error) {
console.error('Chat send error:', error)
toast.error(t('chat.assistantError') || 'Failed to send message')
}
}
const handleNewChat = () => {
setCurrentId(null)
setMessages([])
setHistoryMessages([])
setSelectedNotebook(undefined)
setWebSearchEnabled(false)
}
const handleDeleteConversation = async (id: string) => {
try {
await deleteConversation(id)
if (currentId === id) {
handleNewChat()
}
await refreshConversations()
} catch {
toast.error(t('chat.deleteError'))
}
}
// During streaming or if useChat has more messages than history, prefer useChat
const displayMessages = isLoading || messages.length > historyMessages.length
? messages
: historyMessages
// Auto-scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [displayMessages])
return (
<div className="flex-1 flex overflow-hidden bg-background">
<ChatSidebar
conversations={conversations}
currentId={currentId}
onSelect={setCurrentId}
onNew={handleNewChat}
onDelete={handleDeleteConversation}
/>
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
</div>
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-background via-background/90 to-transparent pt-6 pb-4">
<div className="w-full max-w-4xl px-4">
<ChatInput
onSend={handleSendMessage}
isLoading={isLoading}
onStop={stop}
notebooks={notebooks}
currentNotebookId={selectedNotebook || null}
webSearchEnabled={webSearchEnabled}
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
webSearchAvailable={webSearchAvailable}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,159 +0,0 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Send, BookOpen, X, Globe, Square } from 'lucide-react'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
interface ChatInputProps {
onSend: (message: string, notebookId?: string) => void
isLoading?: boolean
onStop?: () => void
notebooks: any[]
currentNotebookId?: string | null
webSearchEnabled?: boolean
onToggleWebSearch?: () => void
webSearchAvailable?: boolean
}
export function ChatInput({ onSend, isLoading, onStop, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
const { t } = useLanguage()
const [input, setInput] = useState('')
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (currentNotebookId) {
setSelectedNotebook(currentNotebookId)
}
}, [currentNotebookId])
const handleSend = () => {
if (!input.trim() || isLoading) return
onSend(input, selectedNotebook)
setInput('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
}, [input])
return (
<div className="w-full relative">
<div className="relative flex flex-col bg-muted/50 rounded-[24px] border border-border/60 shadow-sm focus-within:shadow-md focus-within:border-border transition-all duration-300 overflow-hidden">
{/* Input Area */}
<Textarea
ref={textareaRef}
placeholder={t('chat.placeholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
/>
{/* Bottom Actions Bar */}
<div className="flex items-center justify-between px-3 pb-3 pt-1">
{/* Context Selector */}
<div className="flex items-center gap-2">
<Select
value={selectedNotebook || 'global'}
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
>
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-background border-border/60 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-muted/50 transition-colors">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder={t('chat.allNotebooks')} />
</SelectTrigger>
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
{(() => {
const Icon = getNotebookIcon(nb.icon)
return <Icon className="w-3.5 h-3.5" />
})()} {nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedNotebook && (
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
{t('chat.active')}
</Badge>
)}
{webSearchAvailable && (
<button
type="button"
onClick={onToggleWebSearch}
className={cn(
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
webSearchEnabled
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
: "bg-background border-border/60 text-muted-foreground hover:bg-muted/50"
)}
>
<Globe className="h-3.5 w-3.5" />
{webSearchEnabled && t('chat.webSearch')}
</button>
)}
</div>
{/* Send / Stop Button */}
{isLoading && onStop ? (
<Button
onClick={onStop}
size="icon"
className="rounded-full h-8 w-8 bg-red-500 text-white shadow-sm hover:bg-red-600 transition-all duration-200"
>
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button
disabled={!input.trim() || isLoading}
onClick={handleSend}
size="icon"
className={cn(
"rounded-full h-8 w-8 transition-all duration-200",
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
)}
>
<Send className="h-4 w-4 ml-0.5" />
</Button>
)}
</div>
</div>
<div className="text-center mt-3">
<span className="text-[11px] text-muted-foreground/60 w-full block">
{t('chat.disclaimer')}
</span>
</div>
</div>
)
}

View File

@@ -1,84 +0,0 @@
'use client'
import { User, Bot, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { useLanguage } from '@/lib/i18n'
interface ChatMessagesProps {
messages: any[]
isLoading?: boolean
}
function getMessageContent(msg: any): string {
if (typeof msg.content === 'string') return msg.content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text)
.join('')
}
return ''
}
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
const { t } = useLanguage()
return (
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
<Bot className="h-12 w-12 text-primary opacity-60" />
</div>
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
{t('chat.welcome')}
</p>
</div>
)}
{messages.map((msg, index) => {
const content = getMessageContent(msg)
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
return (
<div
key={msg.id || index}
className={cn(
"flex w-full px-4 md:px-0 py-6 my-2 group",
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
)}
>
{msg.role === 'user' ? (
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-muted text-foreground rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-border/50">
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
) : (
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
</Avatar>
<div dir="auto" className="flex-1 overflow-hidden pt-1">
{content ? (
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : isLastAssistant ? (
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
</div>
) : null}
</div>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -1,127 +0,0 @@
'use client'
import { useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface ChatSidebarProps {
conversations: any[]
currentId?: string | null
onSelect: (id: string) => void
onNew: () => void
onDelete?: (id: string) => void
}
export function ChatSidebar({
conversations,
currentId,
onSelect,
onNew,
onDelete,
}: ChatSidebarProps) {
const { t, language } = useLanguage()
const dateLocale = language === 'fr' ? fr : enUS
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const confirmDelete = (id: string) => {
setPendingDelete(id)
}
const cancelDelete = (e: React.MouseEvent) => {
e.stopPropagation()
setPendingDelete(null)
}
const executeDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
setPendingDelete(null)
if (onDelete) {
await onDelete(id)
}
}
return (
<div className="w-64 border-r flex flex-col h-full bg-sidebar">
<div className="p-4 border-bottom">
<Button
onClick={onNew}
className="w-full justify-start gap-2 shadow-sm"
variant="outline"
>
<Plus className="h-4 w-4" />
{t('chat.newConversation')}
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{conversations.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
{t('chat.noHistory')}
</div>
) : (
conversations.map((chat) => (
<div
key={chat.id}
onClick={() => onSelect(chat.id)}
className={cn(
"relative cursor-pointer rounded-lg transition-all group",
currentId === chat.id
? "bg-primary/10 text-primary dark:bg-primary/20"
: "hover:bg-muted/50 text-muted-foreground"
)}
>
<div className="p-3 flex flex-col gap-1">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 shrink-0" />
<span className="truncate text-sm font-medium pr-6">
{chat.title || t('chat.untitled')}
</span>
</div>
<span className="text-[10px] opacity-60 ml-6" suppressHydrationWarning>
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
</span>
</div>
{/* Delete button — visible on hover or when confirming */}
{pendingDelete !== chat.id && (
<button
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
{/* Inline confirmation banner */}
{pendingDelete === chat.id && (
<div
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
onClick={(e) => e.stopPropagation()}
>
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
<button
onClick={(e) => executeDelete(e, chat.id)}
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
>
{t('chat.yes')}
</button>
<button
onClick={cancelDelete}
className="p-0.5 hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
))
)}
</div>
</div>
)
}

View File

@@ -165,11 +165,11 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
// Remove all action buttons, toolbars, and UI elements
clone.querySelectorAll('button, .drag-handle, [contenteditable="false"], .opacity-0, .group-hover\\:opacity-100').forEach(el => el.remove())
// Remove remaining UI controls (block action icons with tooltips) regardless of
// UI language — keep user content that may carry a title (links, abbreviations).
clone.querySelectorAll('[title]').forEach(el => {
const title = el.getAttribute('title')
if (title && (title.includes('Supprimer') || title.includes('Delete') || title.includes('Désactiver') || title.includes('Disable') || title.includes('Modifier') || title.includes('Edit'))) {
el.remove()
}
const tag = el.tagName.toLowerCase()
if (tag !== 'a' && tag !== 'abbr') el.remove()
})
// Render KaTeX equations properly

View File

@@ -1,789 +0,0 @@
'use client'
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useNotebooks } from '@/context/notebooks-context'
import { openNotePath } from '@/lib/navigation/open-note'
import {
Loader2,
Network,
Filter,
X,
ExternalLink,
Maximize2,
Calendar,
Clock,
Link2,
FileText,
Check,
Tag,
Sparkles,
ChevronRight,
BookOpen
} from 'lucide-react'
import DOMPurify from 'isomorphic-dompurify'
import { markdownToHtml } from '@/lib/markdown-to-html'
import { useLanguage } from '@/lib/i18n'
import { LabelBadge } from './label-badge'
import { NoteChecklist } from './note-checklist'
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false }) as any
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
ssr: false,
loading: () => <div className="h-20 w-full animate-pulse bg-concrete/5 rounded" />
})
interface GraphNode { id: string; title: string; notebookId: string | null; createdAt: string; degree: number }
interface GraphEdge { source: string; target: string; weight: number; type: string }
interface Cluster { id: string; name: string }
interface RawData { nodes: GraphNode[]; edges: GraphEdge[]; clusters: Cluster[] }
interface NotePreview {
id: string
title: string | null
content: string
createdAt: string | Date
updatedAt?: string | Date
labels?: string[] | null
type?: 'text' | 'markdown' | 'richtext' | 'checklist'
checkItems?: { id: string; text: string; checked: boolean }[] | null
isMarkdown?: boolean
}
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
type EdgeTypeKey = 'explicit_link' | 'semantic_echo' | 'title_mention' | 'shared_label' | 'jaccard'
const DEFAULT_EDGE_FILTERS: Record<EdgeTypeKey, boolean> = {
explicit_link: true,
semantic_echo: true,
title_mention: true,
shared_label: true,
jaccard: false,
}
export function NoteGraphView({ embedded = false }: { embedded?: boolean }) {
const router = useRouter()
const { notebooks } = useNotebooks()
const containerRef = useRef<HTMLDivElement>(null)
const graphRef = useRef<any>(null)
const existingNodesRef = useRef<Map<string, any>>(new Map())
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
const [rawData, setRawData] = useState<RawData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchFilter, setSearchFilter] = useState('')
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
const [edgeFilters, setEdgeFilters] = useState(DEFAULT_EDGE_FILTERS)
const [semanticMinWeight, setSemanticMinWeight] = useState(0.45)
const [focusNodeId, setFocusNodeId] = useState<string | null>(null)
const [controlsOpen, setControlsOpen] = useState(!embedded)
const { t, language } = useLanguage()
const plainText = useCallback((html: string | null | undefined) =>
(html ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/#{1,6}\s/g, '')
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
.replace(/_{1,2}([^_]+)_{1,2}/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/!?\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\s+/g, ' ').trim().slice(0, 400), [])
const htmlContent = useMemo(() => {
if (!notePreview?.content) return ''
const isMarkdown = notePreview.type === 'markdown' || notePreview.isMarkdown || (!notePreview.content.includes('<') && !notePreview.content.includes('</'))
let rawHtml = notePreview.content
if (isMarkdown) {
rawHtml = markdownToHtml(notePreview.content)
}
return DOMPurify.sanitize(rawHtml)
}, [notePreview])
const wordCount = useMemo(() => {
if (!notePreview?.content) return 0
const text = plainText(notePreview.content)
return text.split(/\s+/).filter(Boolean).length
}, [notePreview, plainText])
const charCount = useMemo(() => {
if (!notePreview?.content) return 0
return plainText(notePreview.content).length
}, [notePreview, plainText])
const isRtl = useMemo(() => {
if (!notePreview?.content) return false
const sample = plainText(notePreview.content).replace(/\s+/g, '').slice(0, 400)
const rtlChars = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
let rtl = 0
let ltr = 0
for (const ch of sample) {
if (rtlChars.test(ch)) rtl++
else if (/[A-Za-z]/.test(ch)) ltr++
}
return rtl > 0 && rtl >= ltr
}, [notePreview, plainText])
// ─── Resize ───────────────────────────────────────────────────────────────
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect
setDimensions({ width: Math.floor(width), height: Math.floor(height) })
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// ─── Fetch data ───────────────────────────────────────────────────────────
useEffect(() => {
setLoading(true)
fetch('/api/graph')
.then(r => { if (!r.ok) throw new Error(t('errors.network')); return r.json() })
.then(d => setRawData(d))
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [])
// ─── Configure forces once graph is mounted ───────────────────────────────
const forcesConfigured = useRef(false)
useEffect(() => {
if (!rawData || forcesConfigured.current) return
// Wait for the ForceGraph to mount
const timer = setTimeout(() => {
const fg = graphRef.current
if (!fg) return
fg.d3Force('charge')?.strength(-120)
fg.d3Force('link')?.distance(55)
fg.d3Force('center')?.strength(0.05)
forcesConfigured.current = true
}, 200)
return () => clearTimeout(timer)
}, [rawData])
// ─── Note preview ─────────────────────────────────────────────────────────
useEffect(() => {
if (!selectedNode) { setNotePreview(null); return }
setPreviewLoading(true)
fetch(`/api/notes/${selectedNode.id}`)
.then(r => r.ok ? r.json() : null)
.then(res => setNotePreview(res?.data ?? null))
.catch(() => setNotePreview(null))
.finally(() => setPreviewLoading(false))
}, [selectedNode])
// ─── Color map ────────────────────────────────────────────────────────────
const colorMap = useMemo(() => {
if (!rawData) return new Map<string | null, string>()
const map = new Map<string | null, string>()
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))] as string[]
ids.forEach((id, i) => {
const nb = notebooks.find(n => n.id === id)
map.set(id, nb?.color || PALETTE[i % PALETTE.length])
})
return map
}, [rawData, notebooks])
const neighborIds = useMemo(() => {
if (!focusNodeId || !rawData) return null
const ids = new Set<string>([focusNodeId])
for (const edge of rawData.edges) {
if (edge.source === focusNodeId) ids.add(edge.target)
if (edge.target === focusNodeId) ids.add(edge.source)
}
return ids
}, [focusNodeId, rawData])
// ─── Graph data ───────────────────────────────────────────────────────────
const graphData = useMemo(() => {
if (!rawData) return { nodes: [], links: [] }
let filtered = selectedNotebookId
? rawData.nodes.filter(n => n.notebookId === selectedNotebookId)
: rawData.nodes
if (neighborIds) {
filtered = filtered.filter(n => neighborIds.has(n.id))
}
filtered = searchFilter.trim()
? filtered.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
: filtered
const filteredIds = new Set(filtered.map(n => n.id))
const visibleEdges = rawData.edges.filter(e => {
const type = e.type as EdgeTypeKey
if (!(type in edgeFilters) || !edgeFilters[type]) return false
if (type === 'semantic_echo' && e.weight < semanticMinWeight) return false
return filteredIds.has(e.source) && filteredIds.has(e.target)
})
return {
nodes: filtered.map(n => {
const existing = existingNodesRef.current.get(n.id)
if (existing) {
existing.name = n.title
existing.val = 1 + Math.min(n.degree, 8) * 0.5
existing.color = colorMap.get(n.notebookId) ?? '#94a3b8'
existing.notebookId = n.notebookId
existing.degree = n.degree
return existing
}
const newNode = {
id: n.id,
name: n.title,
val: 1 + Math.min(n.degree, 8) * 0.5,
color: colorMap.get(n.notebookId) ?? '#94a3b8',
notebookId: n.notebookId,
degree: n.degree,
}
existingNodesRef.current.set(n.id, newNode)
return newNode
}),
links: visibleEdges.map(e => {
let color = '#cbd5e1'
let width = 2.5
let dash = false
if (e.type === 'explicit_link') {
color = '#10b981'
width = 4.5
} else if (e.type === 'semantic_echo') {
color = '#8b5cf6'
width = 3.5
dash = true
} else if (e.type === 'title_mention') {
color = '#f59e0b'
width = 3.2
} else if (e.type === 'shared_label') {
color = '#3b82f6'
width = 2.8
}
return {
source: e.source,
target: e.target,
color,
width,
dash,
type: e.type,
}
}),
}
}, [rawData, searchFilter, colorMap, selectedNotebookId, edgeFilters, semanticMinWeight, neighborIds])
const selectedNotebookName = useMemo(() => {
if (!selectedNode || !rawData) return null
return rawData.clusters.find(c => c.id === selectedNode.notebookId)?.name ?? null
}, [selectedNode, rawData])
// ─── Handlers (double-click via timer) ──────────────────────────────────
const lastClickRef = useRef<{ id: string; time: number } | null>(null)
const handleNodeClick = useCallback((node: any) => {
if (!rawData) return
const now = Date.now()
const last = lastClickRef.current
if (last && last.id === node.id && now - last.time < 350) {
lastClickRef.current = null
router.push(openNotePath(node.id))
return
}
lastClickRef.current = { id: node.id, time: now }
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
}, [rawData, router])
const handleZoomToFit = useCallback(() => {
graphRef.current?.zoomToFit(400, 50)
}, [])
const toggleEdgeFilter = useCallback((key: EdgeTypeKey) => {
setEdgeFilters(prev => ({ ...prev, [key]: !prev[key] }))
}, [])
// Zoom vers le premier nœud correspondant à la recherche
useEffect(() => {
if (!searchFilter.trim() || graphData.nodes.length === 0) return
const timer = window.setTimeout(() => {
const fg = graphRef.current
if (!fg) return
const match = fg.graphData()?.nodes?.find((n: { id: string; name?: string }) =>
(n.name ?? '').toLowerCase().includes(searchFilter.toLowerCase())
)
if (match?.x != null && match?.y != null) {
fg.centerAt(match.x, match.y, 500)
fg.zoom(2.2, 500)
}
}, 600)
return () => window.clearTimeout(timer)
}, [searchFilter, graphData.nodes.length])
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
const dataRef = useRef<{ nodes: any[]; colorMap: Map<string|null,string>; clusters: Cluster[] }>({ nodes: [], colorMap: new Map(), clusters: [] })
dataRef.current = { nodes: graphData.nodes, colorMap, clusters: rawData?.clusters ?? [] }
const paintClusters = useRef((ctx: CanvasRenderingContext2D, globalScale: number) => {
const { nodes, colorMap: cm, clusters } = dataRef.current
if (!nodes || nodes.length === 0) return
const groups = new Map<string, { x: number; y: number }[]>()
for (const node of nodes) {
if (!node.notebookId || node.x === undefined || node.y === undefined) continue
if (!groups.has(node.notebookId)) groups.set(node.notebookId, [])
groups.get(node.notebookId)!.push({ x: node.x, y: node.y })
}
for (const [nbId, pts] of groups) {
if (pts.length < 3) continue
const color = cm.get(nbId) ?? '#94a3b8'
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length
let maxR = 0
for (const p of pts) {
const d = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2)
if (d > maxR) maxR = d
}
const r = maxR + 30
ctx.beginPath()
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
ctx.fillStyle = color + '0A'
ctx.fill()
ctx.strokeStyle = color + '30'
ctx.lineWidth = 1.5 / globalScale
ctx.setLineDash([5 / globalScale, 5 / globalScale])
ctx.stroke()
ctx.setLineDash([])
// Cluster name
if (globalScale > 0.4) {
const name = clusters.find(c => c.id === nbId)?.name ?? ''
if (name) {
const fs = Math.min(12, 9 / globalScale)
ctx.font = `600 ${fs}px -apple-system, sans-serif`
ctx.fillStyle = color + 'BB'
ctx.textAlign = 'center'
ctx.textBaseline = 'bottom'
ctx.fillText(name, cx, cy - r + 4)
}
}
}
}).current
// ─── Render ───────────────────────────────────────────────────────────────
return (
<div className={`flex flex-col h-full ${embedded ? 'bg-transparent' : 'bg-[#FAFAF9]'}`}>
{!embedded && (
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
<Network size={16} className="text-indigo-500" />
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
{rawData && (
<span className="text-[10px] text-concrete/50 font-medium">
{t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })}
{graphData.links.length !== rawData.edges.length && (
<> · {t('graphView.visibleConnections', { count: graphData.links.length })}</>
)}
</span>
)}
<div className="flex-1" />
<div className="relative">
<Filter size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-concrete/40" />
<input
type="text"
placeholder={t('graphView.searchPlaceholder')}
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
className="pl-7 pr-7 py-1.5 bg-white border border-border/60 rounded-md text-xs text-ink outline-none focus:border-indigo-400 w-44 placeholder:text-concrete/40"
/>
{searchFilter && (
<button onClick={() => setSearchFilter('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-concrete/40 hover:text-ink">
<X size={12} />
</button>
)}
</div>
</div>
)}
{/* Canvas */}
<div ref={containerRef} className="flex-1 relative overflow-hidden">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#FAFAF9]">
<Loader2 size={24} className="animate-spin text-concrete/40" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm text-rose-500">{error}</p>
</div>
)}
{!loading && !error && graphData.nodes.length > 0 && (
<ForceGraph2D
ref={graphRef}
graphData={graphData}
width={dimensions.width}
height={dimensions.height}
backgroundColor="#FAFAF9"
nodeRelSize={5}
nodeVal="val"
nodeColor="color"
nodeLabel="name"
linkColor="color"
linkWidth="width"
linkOpacity={0.92}
linkDirectionalParticles={2}
linkDirectionalParticleWidth={2.5}
linkLineDash={(link: any) => link.dash ? [6, 4] : null}
onNodeClick={handleNodeClick}
onNodeHover={(node: any) => {
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
}}
onRenderFramePre={paintClusters}
nodeCanvasObjectMode={() => 'after'}
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const n = node as any
if (globalScale < 0.7) return
const name: string = n.name ?? ''
const label = name.length > 20 ? name.slice(0, 18) + '…' : name
const fontSize = 11 / globalScale
if (fontSize > 18) return
ctx.font = `${fontSize}px -apple-system, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const r = Math.sqrt(n.val ?? 1) * 5
// White background behind label
const tw = ctx.measureText(label).width
const lx = n.x - tw / 2 - 2
const ly = n.y + r + 2
ctx.fillStyle = 'rgba(250,250,249,0.85)'
ctx.fillRect(lx, ly, tw + 4, fontSize + 2)
// Label text
ctx.fillStyle = '#334155'
ctx.fillText(label, n.x, ly + 1)
}}
cooldownTicks={80}
d3AlphaDecay={0.03}
d3VelocityDecay={0.4}
/>
)}
{!loading && !error && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-concrete/40">
<Network size={32} />
<p className="text-xs">{t('graphView.noNotesFound')}</p>
</div>
)}
{/* Zoom to fit */}
{!loading && graphData.nodes.length > 0 && (
<button
onClick={(e) => { e.stopPropagation(); handleZoomToFit() }}
className="absolute top-4 right-4 z-10 flex items-center gap-1.5 px-3 py-1.5 bg-white border border-border/50 rounded-md text-[11px] text-ink font-medium shadow-sm hover:bg-gray-50 transition-colors animate-fade-in"
>
<Maximize2 size={12} />
{t('graphView.globalView')}
</button>
)}
{/* Cluster legend (Interactive Notebook Filter) */}
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
<div
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[42vh] overflow-y-auto pr-1"
>
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">{t('graphView.notebooks')}</span>
{(selectedNotebookId || focusNodeId) && (
<div className="flex flex-col gap-1">
{selectedNotebookId && (
<button
onClick={() => setSelectedNotebookId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
{t('graphView.resetFilter')}
</button>
)}
{focusNodeId && (
<button
onClick={() => setFocusNodeId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-indigo-200 text-indigo-600 rounded-full shadow-sm hover:bg-indigo-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
{t('graphView.resetFocus')}
</button>
)}
</div>
)}
<div className="flex flex-col gap-1.5">
{rawData.clusters.map(c => {
const isSelected = selectedNotebookId === c.id
const isAnySelected = selectedNotebookId !== null
const color = colorMap.get(c.id) ?? '#94a3b8'
return (
<button
key={c.id}
onClick={() => setSelectedNotebookId(isSelected ? null : c.id)}
className={`flex items-center gap-2 px-3 py-1 bg-white border rounded-full shadow-sm transition-all duration-200 hover:scale-105 w-fit text-left ${
isSelected
? 'border-indigo-500 ring-2 ring-indigo-500/20 font-semibold'
: isAnySelected
? 'border-border/30 opacity-40 hover:opacity-100'
: 'border-border/30 hover:border-concrete/40'
}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
<span className="text-[10px] text-concrete/80 whitespace-nowrap">{c.name}</span>
{isSelected && <X size={10} className="text-concrete/40 ml-0.5 shrink-0" />}
</button>
)
})}
</div>
</div>
)}
{/* Filtres de liens + seuil sémantique */}
{!loading && !error && rawData && (
<div
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 max-w-[220px]"
>
<button
type="button"
onClick={() => setControlsOpen(v => !v)}
className="flex items-center gap-2 px-3 py-1.5 bg-white/95 border border-border/40 rounded-lg shadow-sm text-[10px] font-semibold text-slate-700 w-fit"
>
<Filter size={12} />
{t('graphView.linkFilters')}
</button>
{controlsOpen && (
<div className="p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm space-y-2.5 select-none">
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider">{t('graphView.relationshipTypes')}</h3>
{([
['explicit_link', t('graphView.edgeTypes.explicitLink')],
['semantic_echo', t('graphView.edgeTypes.semanticEcho')],
['title_mention', t('graphView.edgeTypes.titleMention')],
['shared_label', t('graphView.edgeTypes.sharedLabel')],
['jaccard', t('graphView.edgeTypes.jaccard')],
] as [EdgeTypeKey, string][]).map(([key, label]) => (
<label key={key} className="flex items-center gap-2.5 cursor-pointer text-[10px] text-slate-700">
<input
type="checkbox"
checked={edgeFilters[key]}
onChange={() => toggleEdgeFilter(key)}
className="w-3.5 h-3.5 shrink-0 rounded border-2 border-slate-300 accent-indigo-600 cursor-pointer"
/>
<span>{label}</span>
</label>
))}
{edgeFilters.semantic_echo && (
<div className="pt-1 border-t border-border/30 space-y-1">
<div className="flex items-center justify-between text-[9px] text-concrete/70">
<span>{t('graphView.semanticThreshold')}</span>
<span className="font-mono">{Math.round(semanticMinWeight * 100)}%</span>
</div>
<input
type="range"
min={0.3}
max={0.9}
step={0.05}
value={semanticMinWeight}
onChange={e => setSemanticMinWeight(Number(e.target.value))}
className="w-full h-1 accent-indigo-500"
/>
</div>
)}
</div>
)}
</div>
)}
{/* Legend of relationship types (compact) */}
{!loading && !error && graphData.nodes.length > 0 && controlsOpen && (
<div className="absolute bottom-4 right-[21rem] z-10 hidden xl:flex flex-col gap-1.5 p-2.5 bg-white/90 border border-border/40 rounded-lg shadow-sm max-w-xs select-none pointer-events-none opacity-80">
<div className="flex items-center gap-2">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.explicitLink')}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
<span className="text-[9px] font-medium text-concrete/70">{t('graphView.edgeTypes.semanticEcho')}</span>
</div>
</div>
)}
{/* Note detail panel */}
{selectedNode && (
<div
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
className="absolute inset-y-0 right-0 w-80 backdrop-blur-md bg-white/95 dark:bg-stone-900/95 border-l border-border/40 flex flex-col shadow-[0_8px_30px_rgb(0,0,0,0.06)] z-20 transition-all duration-300"
>
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-border/40">
<div className="flex-1 min-w-0 pr-3">
{selectedNotebookName && (
<button
onClick={() => setSelectedNotebookId(selectedNode.notebookId === selectedNotebookId ? null : selectedNode.notebookId)}
className="mb-2 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider bg-slate-100 hover:bg-slate-200/80 text-concrete transition-all border border-border/20 hover:scale-105"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(selectedNode.notebookId) ?? '#94a3b8' }} />
{selectedNotebookName}
</button>
)}
<h2
dir={isRtl ? 'rtl' : 'ltr'}
className={`text-sm font-semibold text-slate-800 dark:text-slate-100 leading-snug tracking-tight select-all ${isRtl ? 'text-right font-persian font-semibold' : 'text-left font-sans'}`}
>
{selectedNode.title || <span className="italic text-concrete/40">{t('notes.untitled')}</span>}
</h2>
</div>
<button
onClick={() => setSelectedNode(null)}
className="p-1.5 rounded-full text-concrete/40 hover:text-slate-800 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-stone-800 transition-all"
>
<X size={14} />
</button>
</div>
{/* Quick Metadata Info */}
<div className="px-5 py-3.5 bg-slate-50/50 dark:bg-stone-950/20 border-b border-border/30 grid grid-cols-2 gap-y-2 gap-x-4 text-[10px] text-concrete/60 select-none">
<div className="flex items-center gap-1.5">
<Calendar size={11} className="text-concrete/40 shrink-0" />
<span className="truncate" title={new Date(selectedNode.createdAt).toLocaleString(language === 'fa' ? 'fa-IR' : language)}>
{new Date(selectedNode.createdAt).toLocaleDateString(language === 'fa' ? 'fa-IR' : language, { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
<div className="flex items-center gap-1.5">
<Link2 size={11} className="text-concrete/40 shrink-0" />
<span>
{t(selectedNode.degree === 1 ? 'graphView.connections' : 'graphView.connectionsPlural', { count: selectedNode.degree })}
</span>
</div>
{!previewLoading && notePreview && (
<>
<div className="flex items-center gap-1.5">
<FileText size={11} className="text-concrete/40 shrink-0" />
<span>{t('graphView.preview.words', { count: wordCount })}</span>
</div>
{notePreview.updatedAt && (
<div className="flex items-center gap-1.5 col-span-2 border-t border-border/20 pt-1.5 mt-0.5">
<Clock size={11} className="text-concrete/40 shrink-0" />
<span className="truncate">
{t('graphView.preview.updated')}{' '}
{new Date(notePreview.updatedAt).toLocaleDateString(language === 'fa' ? 'fa-IR' : language, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
)}
</>
)}
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{previewLoading ? (
/* Sleek Skeleton Loader */
<div className="space-y-4 animate-pulse">
<div className="h-4 bg-stone-200/60 dark:bg-stone-800/60 rounded w-3/4" />
<div className="space-y-2">
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-11/12" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-4/5" />
</div>
<div className="space-y-2 pt-2">
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-5/6" />
</div>
</div>
) : !notePreview || (!notePreview.content && (!notePreview.checkItems || notePreview.checkItems.length === 0)) ? (
<div className="flex flex-col items-center justify-center py-16 text-concrete/30 gap-2.5">
<FileText size={32} className="stroke-[1.2] text-concrete/20" />
<p className="text-xs italic font-medium">{t('graphView.preview.emptyNote')}</p>
</div>
) : (
<>
{/* Note Content Renderer */}
{notePreview.type === 'checklist' && notePreview.checkItems && notePreview.checkItems.length > 0 ? (
<div className="space-y-2 select-none" dir={isRtl ? 'rtl' : 'ltr'}>
<NoteChecklist
items={notePreview.checkItems}
onToggleItem={() => {}}
/>
</div>
) : notePreview.type === 'markdown' || notePreview.isMarkdown ? (
<div className="text-xs text-slate-600 dark:text-stone-300" dir={isRtl ? 'rtl' : 'ltr'}>
<MarkdownContent
content={notePreview.content}
className={`prose-h1:text-sm prose-h1:font-bold prose-h1:text-slate-800 dark:prose-h1:text-stone-100 prose-h1:mt-3 prose-h1:mb-1 prose-h2:text-xs prose-h2:font-bold prose-h2:text-slate-700 dark:prose-h2:text-stone-200 prose-h2:mt-2 prose-h2:mb-1 prose-p:text-xs prose-p:leading-relaxed prose-p:mb-2 prose-ul:list-disc prose-ul:pl-4 prose-ol:list-decimal prose-ol:pl-4 ${isRtl ? 'text-right font-persian' : 'text-left font-sans'}`}
/>
</div>
) : (
<div
dir={isRtl ? 'rtl' : 'ltr'}
className={`text-xs text-slate-600 dark:text-stone-300 space-y-2 leading-relaxed break-words ${isRtl ? 'text-right font-persian' : 'text-left font-sans'}
[&_h1]:text-sm [&_h1]:font-bold [&_h1]:text-slate-800 dark:[&_h1]:text-stone-100 [&_h1]:mt-4 [&_h1]:mb-1.5 [&_h1]:first:mt-0
[&_h2]:text-xs [&_h2]:font-bold [&_h2]:text-slate-700 dark:[&_h2]:text-stone-200 [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:first:mt-0
[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-slate-600 dark:[&_h3]:text-stone-300 [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:first:mt-0
[&_p]:mb-2 [&_p]:last:mb-0
[&_ul]:list-disc [&_ul]:pl-4 [&_ul]:mb-2
[&_ol]:list-decimal [&_ol]:pl-4 [&_ol]:mb-2
[&_li]:mb-0.5
[&_strong]:font-semibold [&_strong]:text-slate-800 dark:[&_strong]:text-stone-100
[&_em]:italic
[&_code]:px-1 [&_code]:py-0.5 [&_code]:bg-slate-100 dark:[&_code]:bg-stone-850 [&_code]:rounded [&_code]:font-mono [&_code]:text-[10px]
[&_pre]:p-2.5 [&_pre]:bg-slate-900 [&_pre]:text-slate-100 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:font-mono [&_pre]:text-[10px] [&_pre]:my-2
[&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 dark:[&_blockquote]:border-stone-700 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-500 [&_blockquote]:my-2
[&_a]:text-indigo-600 dark:[&_a]:text-indigo-400 [&_a]:underline [&_a]:hover:text-indigo-500`}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
)}
{/* Refined Tags list */}
{Array.isArray(notePreview.labels) && notePreview.labels.length > 0 && (
<div className="border-t border-border/20 pt-4 mt-4 select-none">
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-800 dark:text-stone-300 uppercase tracking-wider mb-2">
<Tag size={10} className="text-concrete/40 shrink-0" />
<span>{t('graphView.preview.tags')}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{notePreview.labels.map((label: string) => (
<LabelBadge key={label} label={label} variant="default" />
))}
</div>
</div>
)}
</>
)}
</div>
{/* Premium Action Footer */}
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20 space-y-2">
<button
type="button"
onClick={() => setFocusNodeId(prev => prev === selectedNode.id ? null : selectedNode.id)}
className="w-full flex items-center justify-center gap-2 py-2 px-4 bg-white dark:bg-stone-900 border border-border/50 hover:border-indigo-400 text-xs font-medium rounded-lg transition-colors"
>
<Sparkles size={12} className="text-indigo-500" />
<span>{focusNodeId === selectedNode.id ? t('graphView.resetFocus') : t('graphView.exploreFromNode')}</span>
</button>
<button
onClick={() => router.push(openNotePath(selectedNode.id))}
className="group w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-brand-accent hover:bg-brand-accent/90 text-white active:scale-[0.98] text-xs font-semibold rounded-lg shadow-sm transition-all duration-200"
>
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />
<span>{t('graphView.preview.openNote')}</span>
<ChevronRight size={12} className="group-hover:translate-x-0.5 transition-transform ml-0.5" />
</button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -565,7 +565,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
{overviewLoading ? (
<div className="flex items-center gap-2">
<Loader2 size={12} className="animate-spin text-brand-accent" />
<span className="text-[11px] text-muted-foreground">t('searchModal.resultsSummary')</span>
<span className="text-[11px] text-muted-foreground">{t('searchModal.resultsSummary')}</span>
</div>
) : (
<p className="text-[12px] leading-relaxed text-foreground/80">{overview}</p>
@@ -687,7 +687,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
<div className="space-y-1">
<p className="text-[11.5px] font-bold">{t('searchModal.documentPreview')}</p>
<p className="text-[10px] italic opacity-60">
Sélectionnez un résultat pour explorer son contenu.
{t('searchModal.selectResultToPreview')}
</p>
</div>
</div>

View File

@@ -1536,6 +1536,22 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</span>
</Link>
<Link
href="/archive"
className={cn(
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
pathname === '/archive'
? 'bg-amber-500/10 text-amber-500 border border-amber-500/25'
: 'text-concrete hover:text-amber-500 hover:bg-amber-500/5'
)}
>
{pathname === '/archive' && <div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-4 bg-amber-500 rounded-r-full" />}
<Archive 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.archive')}
</span>
</Link>
<Link
href="/home?shared=1&forceList=1"
className={cn(
@@ -1558,7 +1574,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
>
<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)
{t('sidebar.searchShortcut')}
</span>
</button>
@@ -1568,7 +1584,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
>
{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'}
{isDark ? t('sidebar.lightMode') : t('sidebar.darkMode')}
</span>
</button>