refactor: factorisation peek panel — NotePeekPanel réutilisable
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m6s
CI / Deploy production (on server) (push) Successful in 26s

Architecture:
- components/note-peek/use-note-peek.ts: hook (fetch + state + events)
- components/note-peek/note-peek-content.tsx: rendu markdown/richtext/KaTeX
- components/note-peek/note-peek-panel.tsx: panel (overlay + inline modes)
- components/note-peek/index.ts: exports
- lib/use-scroll-to-block.ts: utilitaire scroll vers data-id

insights/page.tsx:
- ~95 lignes (state + fetch + KaTeX useEffect + AnimatePresence) → 10 lignes
- peek.open(noteId) remplace handleNoteClick complexe
- <NotePeekPanel mode=overlay /> remplace tout le JSX du panel

NotePeekPanel gère:
- markdown (MarkdownContent) + richtext (DOMPurify + KaTeX lazy)
- RTL (slide gauche pour fa/ar)
- prefers-reduced-motion
- role=dialog + aria-modal (overlay mode)
- loading spinner
- bouton Maximize2 + X
- renderContent prop pour custom (éditeur read-only)
This commit is contained in:
Antigravity
2026-07-04 23:37:37 +00:00
parent 45fc178589
commit 261eee2953
7 changed files with 299 additions and 121 deletions

View File

@@ -25,12 +25,7 @@ import {
} from 'lucide-react'
import { toast } from 'sonner'
import Link from 'next/link'
import { getNoteById } from '@/app/actions/notes'
import type { Note as NoteFull } from '@/lib/types'
import { X, Maximize2 } from 'lucide-react'
import DOMPurify from 'dompurify'
import { MarkdownContent } from '@/components/markdown-content'
import 'katex/dist/katex.min.css'
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
const NetworkGraph = dynamic(
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
@@ -105,8 +100,7 @@ export default function InsightsPage() {
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
const [lastSyncTime, setLastSyncTime] = useState<string>('')
const [peekNote, setPeekNote] = useState<NoteFull | null>(null)
const [peekLoading, setPeekLoading] = useState(false)
const peek = useNotePeek()
const prefersReducedMotion = useReducedMotion()
useEffect(() => {
@@ -265,40 +259,8 @@ export default function InsightsPage() {
}
}
const handleNoteClick = async (noteId: string) => {
setPeekLoading(true)
try {
const note = await getNoteById(noteId)
if (note) setPeekNote(note)
} catch {
toast.error(t('general.error'))
} finally {
setPeekLoading(false)
}
}
const closePeek = () => setPeekNote(null)
// Rendu KaTeX pour les notes richtext (data-latex)
useEffect(() => {
if (!peekNote || peekNote.isMarkdown) return
const timer = setTimeout(async () => {
const container = document.getElementById('insights-peek-content')
if (!container) return
const katex = (await import('katex')).default
container.querySelectorAll('.math-equation-block[data-latex], .inline-math[data-latex]').forEach(el => {
const latex = el.getAttribute('data-latex') || ''
const isDisplay = el.classList.contains('math-equation-block')
try { el.innerHTML = katex.renderToString(latex, { displayMode: isDisplay, throwOnError: false }) } catch {}
})
}, 100)
return () => clearTimeout(timer)
}, [peekNote])
const openPeekFully = () => {
if (!peekNote) return
router.push(`/home?openNote=${peekNote.id}`)
setPeekNote(null)
const handleNoteClick = (noteId: string) => {
peek.open(noteId)
}
const isRtl = locale === 'fa' || locale === 'ar'
@@ -910,83 +872,15 @@ export default function InsightsPage() {
</div>
)}
{/* ── Peek panel (slide from right) ── */}
<AnimatePresence>
{peekNote && (
<motion.aside
key="insights-peek"
initial={{ x: isRtl ? '-100%' : '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: isRtl ? '-100%' : '100%', opacity: 0 }}
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', stiffness: 340, damping: 34 }}
className={`fixed top-0 ${isRtl ? 'left-0' : 'right-0'} z-[80] h-full w-full sm:w-[min(50vw,640px)] bg-[#fafaf9] dark:bg-zinc-950 flex flex-col overflow-hidden shadow-2xl ${isRtl ? 'border-r' : 'border-l'} border-black/10 dark:border-white/10`}
>
{/* Peek header */}
<div className="shrink-0 px-5 py-3 flex items-center justify-between gap-3 border-b border-black/[0.06] dark:border-white/[0.06] bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-concrete truncate">
{peekLoading ? t('insightsView.loading') : (peekNote.title || t('insightsView.unknownNote'))}
</span>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={openPeekFully}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400 hover:bg-blue-500/10 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<Maximize2 size={12} />
</button>
<button
onClick={closePeek}
className="p-1.5 rounded-lg text-concrete hover:text-ink dark:hover:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
aria-label="Close"
>
<X size={16} />
</button>
</div>
</div>
{/* Peek content */}
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar">
<div className="max-w-2xl mx-auto w-full px-6 sm:px-8 py-8 pb-24">
<h2 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-6">
{peekNote.title || t('insightsView.unknownNote')}
</h2>
{peekNote.content ? (
peekNote.isMarkdown ? (
<MarkdownContent content={peekNote.content} className="prose-lg" />
) : (
<div
id="insights-peek-content"
dir="auto"
className="editor-body prose prose-lg dark:prose-invert max-w-none leading-relaxed text-ink dark:text-dark-ink
[&_h1]:text-2xl [&_h1]:font-serif [&_h1]:font-semibold [&_h1]:mt-8 [&_h1]:mb-4
[&_h2]:text-xl [&_h2]:font-serif [&_h2]:font-semibold [&_h2]:mt-6 [&_h2]:mb-3
[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-5 [&_h3]:mb-2
[&_p]:my-3 [&_p]:leading-relaxed
[&_ul]:list-disc [&_ul]:ps-6 [&_ul]:my-3
[&_ol]:list-decimal [&_ol]:ps-6 [&_ol]:my-3
[&_li]:my-1
[&_blockquote]:border-l-4 [&_blockquote]:border-brand-accent [&_blockquote]:ps-4 [&_blockquote]:italic [&_blockquote]:text-muted-foreground [&_blockquote]:my-4
[&_pre]:bg-zinc-100 [&_pre]:dark:bg-zinc-900 [&_pre]:p-4 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:my-4
[&_code]:bg-zinc-100 [&_code]:dark:bg-zinc-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_code]:font-mono
[&_a]:text-blue-600 [&_a]:underline
[&_img]:rounded-lg [&_img]:max-w-full [&_img]:my-4
[&_table]:w-full [&_table]:my-4 [&_table]:border-collapse
[&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:bg-muted [&_th]:font-semibold [&_th]:text-left
[&_td]:border [&_td]:border-border [&_td]:p-2
[&_hr]:border-border [&_hr]:my-6
[&_[data-callout-type]]:p-4 [&_[data-callout-type]]:rounded-lg [&_[data-callout-type]]:my-4
[&_div[data-type='toggle-block']]:my-3
[&_div[data-type='toggle-block']_details]:rounded-lg [&_div[data-type='toggle-block']_details]:border [&_div[data-type='toggle-block']_details]:border-border"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(peekNote.content) }}
/>
)
) : (
<p className="text-sm text-concrete italic"></p>
)}
</div>
</div>
</motion.aside>
)}
</AnimatePresence>
{/* ── Peek panel (factorisé) ── */}
<NotePeekPanel
note={peek.note}
blockId={peek.blockId}
loading={peek.loading}
mode="overlay"
onClose={peek.close}
onOpenFully={(n) => { router.push(`/home?openNote=${n.id}`); peek.close() }}
/>
</div>
)