feat: add slides generation tool with multiple slide types
- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types - Chart types: bar, horizontal-bar, line, donut, radar - Integrate with agent executor and canvas system - Add multilingual support (en/fr) - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -310,7 +310,7 @@ export function AgentDetailView({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="min-h-full flex flex-col"
|
||||
>
|
||||
<header className="px-12 py-10 border-b border-border bg-background/80 backdrop-blur-md sticky top-0 z-30">
|
||||
<header className="px-4 sm:px-8 md:px-12 py-5 sm:py-8 md:py-10 border-b border-border bg-background/80 backdrop-blur-md sticky top-0 z-30">
|
||||
<div className="flex items-center justify-between max-w-5xl mx-auto">
|
||||
<button
|
||||
onClick={onBack}
|
||||
@@ -348,7 +348,7 @@ export function AgentDetailView({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 px-12 py-16 max-w-5xl mx-auto w-full space-y-16">
|
||||
<div className="flex-1 px-4 sm:px-8 md:px-12 py-8 sm:py-12 md:py-16 max-w-5xl mx-auto w-full space-y-8 sm:space-y-12 md:space-y-16">
|
||||
<section className="space-y-8">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -279,7 +279,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
<div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
<p className="text-[11px] italic leading-relaxed px-12">{t('ai.welcomeMsg')}</p>
|
||||
<p className="text-[11px] italic leading-relaxed px-6">{t('ai.welcomeMsg')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
Users,
|
||||
Globe,
|
||||
Lock,
|
||||
Menu,
|
||||
Star,
|
||||
Sparkles,
|
||||
List,
|
||||
LayoutGrid,
|
||||
X,
|
||||
Edit2,
|
||||
} from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -36,6 +43,9 @@ import {
|
||||
useAddManualIdea,
|
||||
useBrainstormActivity,
|
||||
useUpdateBrainstormSettings,
|
||||
useStarIdea,
|
||||
useSummarizeBrainstorm,
|
||||
useRenameBrainstormSession,
|
||||
} from '@/hooks/use-brainstorm'
|
||||
import { useBrainstormSocket } from '@/hooks/use-brainstorm-socket'
|
||||
import { LiveCursors, PresenceAvatars, useCursorTracking } from '@/components/brainstorm/live-cursors'
|
||||
@@ -106,12 +116,20 @@ export function BrainstormPage() {
|
||||
const joinMutation = useJoinBrainstorm()
|
||||
const addManualIdea = useAddManualIdea(activeSessionId || '')
|
||||
const { data: activities } = useBrainstormActivity(activeSessionId)
|
||||
const starIdea = useStarIdea(activeSessionId || '')
|
||||
const summarize = useSummarizeBrainstorm(activeSessionId || '')
|
||||
const renameSession = useRenameBrainstormSession(activeSessionId || '')
|
||||
const [impactToast, setImpactToast] = useState<{ notesEnriched: number; notesMarkedDry: number } | null>(null)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [exportToast, setExportToast] = useState<{ noteTitle: string; notebookName: string } | null>(null)
|
||||
const [convertToast, setConvertToast] = useState<{ noteTitle: string; noteId: string } | null>(null)
|
||||
const [remoteMove, setRemoteMove] = useState<{ ideaId: string; x: number; y: number; _seq: number } | null>(null)
|
||||
const [playbackIdeas, setPlaybackIdeas] = useState<any[] | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'canvas' | 'list'>('canvas')
|
||||
const [summaryOpen, setSummaryOpen] = useState(false)
|
||||
const [summaryText, setSummaryText] = useState<string | null>(null)
|
||||
const [renamingSession, setRenamingSession] = useState<string | null>(null)
|
||||
const [renameInput, setRenameInput] = useState('')
|
||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const moveSeq = useRef(0)
|
||||
@@ -260,11 +278,35 @@ export function BrainstormPage() {
|
||||
addManualIdea.mutate({ title, parentIdeaId, locale: language })
|
||||
}, [addManualIdea, language])
|
||||
|
||||
const handleStarIdea = async (ideaId: string) => {
|
||||
try { await starIdea.mutateAsync(ideaId) } catch {}
|
||||
}
|
||||
|
||||
const handleSummarize = async () => {
|
||||
setSummaryOpen(true)
|
||||
if (summaryText) return
|
||||
try {
|
||||
const text = await summarize.mutateAsync(language)
|
||||
setSummaryText(text)
|
||||
} catch { setSummaryText(null) }
|
||||
}
|
||||
|
||||
const handleRenameSession = async (sessionId: string) => {
|
||||
if (!renameInput.trim() || renameInput.trim() === session?.seedIdea) {
|
||||
setRenamingSession(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await renameSession.mutateAsync(renameInput.trim())
|
||||
} catch {}
|
||||
setRenamingSession(null)
|
||||
}
|
||||
|
||||
const isGenerating = createBrainstorm.isPending || expandIdea.isPending
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#F8F7F2] dark:bg-[#0A0A0A] overflow-hidden">
|
||||
<div className="p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-black/20 z-10 relative overflow-hidden">
|
||||
<div className="p-4 sm:p-8 md:p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-black/20 z-10 relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
|
||||
style={{
|
||||
@@ -275,7 +317,15 @@ export function BrainstormPage() {
|
||||
/>
|
||||
|
||||
<div className="max-w-4xl mx-auto relative">
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
{/* Hamburger mobile */}
|
||||
<button
|
||||
className="md:hidden absolute -top-1 -start-1 p-2 text-foreground/70 hover:bg-foreground/5 rounded-lg transition-colors"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
||||
aria-label="Ouvrir la navigation"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-3 sm:gap-5 mb-4 sm:mb-8 ps-9 md:ps-0">
|
||||
<motion.div
|
||||
animate={{ rotate: isGenerating ? 360 : 0 }}
|
||||
transition={{
|
||||
@@ -283,12 +333,12 @@ export function BrainstormPage() {
|
||||
duration: 20,
|
||||
ease: 'linear',
|
||||
}}
|
||||
className="w-14 h-14 rounded-2xl bg-brand-accent shadow-[0_0_20px_rgba(164,113,72,0.2)] flex items-center justify-center text-white"
|
||||
className="w-10 h-10 sm:w-14 sm:h-14 rounded-xl sm:rounded-2xl bg-brand-accent shadow-[0_0_20px_rgba(164,113,72,0.2)] flex items-center justify-center text-white shrink-0"
|
||||
>
|
||||
<Wind size={28} />
|
||||
</motion.div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-4xl font-serif font-medium text-foreground tracking-tight">
|
||||
<h1 className="text-2xl sm:text-4xl font-serif font-medium text-foreground tracking-tight">
|
||||
{t('brainstorm.title')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
@@ -302,8 +352,8 @@ export function BrainstormPage() {
|
||||
{session && !isGuest && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exportBrainstorm.isPending}
|
||||
onClick={handleSummarize}
|
||||
disabled={summarize.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-brand-accent transition-all shadow-sm disabled:opacity-50"
|
||||
title={t('brainstorm.export') || 'Export'}
|
||||
>
|
||||
@@ -375,7 +425,7 @@ export function BrainstormPage() {
|
||||
onChange={(e) => setSeedInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm()}
|
||||
placeholder={t('brainstorm.placeholder') || 'Enter a concept to unfold...'}
|
||||
className="w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-8 py-7 pr-20 outline-none transition-all text-2xl font-serif italic text-foreground shadow-sm group-hover:shadow-md border-border/40 focus:border-brand-accent/40 focus:ring-4 focus:ring-brand-accent/5"
|
||||
className="w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-4 sm:px-8 py-4 sm:py-7 pr-16 sm:pr-20 outline-none transition-all text-lg sm:text-2xl font-serif italic text-foreground shadow-sm group-hover:shadow-md border-border/40 focus:border-brand-accent/40 focus:ring-4 focus:ring-brand-accent/5"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleStartBrainstorm()}
|
||||
@@ -413,6 +463,52 @@ export function BrainstormPage() {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Seed prompts — shown only when no session and not generating */}
|
||||
{!session && !createBrainstorm.isPending && !activeSessionId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="mt-5 flex flex-wrap gap-2"
|
||||
>
|
||||
{[
|
||||
t('brainstorm.exampleSeed1') || 'Simplify my morning routine',
|
||||
t('brainstorm.exampleSeed2') || 'Ideas for a creative project this weekend',
|
||||
t('brainstorm.exampleSeed3') || 'Systems to manage my energy better',
|
||||
t('brainstorm.exampleSeed4') || 'What I learned this week',
|
||||
].map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
onClick={() => handleStartBrainstorm(example)}
|
||||
className="px-4 py-2 rounded-full border border-border bg-white dark:bg-white/5 text-sm text-muted-foreground hover:text-foreground hover:border-brand-accent/40 hover:bg-brand-accent/5 transition-all"
|
||||
>
|
||||
{example}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* View mode toggle — shown on mobile only when a session is active */}
|
||||
{session && (
|
||||
<div className="mt-4 md:hidden flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode('canvas')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-widest transition-all ${viewMode === 'canvas' ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-foreground/5'}`}
|
||||
>
|
||||
<LayoutGrid size={12} /> {t('brainstorm.viewCanvas') || 'Canvas'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-widest transition-all ${viewMode === 'list' ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-foreground/5'}`}
|
||||
>
|
||||
<List size={12} /> {t('brainstorm.viewList') || 'List'}
|
||||
</button>
|
||||
<span className="text-[10px] text-muted-foreground ms-auto">
|
||||
{session.ideas.filter(i => i.status !== 'dismissed').length} {t('brainstorm.ideasCount') || 'ideas'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -429,6 +525,48 @@ export function BrainstormPage() {
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-foreground/20 border-t-foreground rounded-full animate-spin" />
|
||||
</div>
|
||||
) : session && viewMode === 'list' ? (
|
||||
/* Mobile list view */
|
||||
<div className="h-full overflow-y-auto p-4 space-y-6">
|
||||
{[1, 2, 3].map(wave => {
|
||||
const waveIdeas = session.ideas.filter(i => i.waveNumber === wave && i.status !== 'dismissed')
|
||||
if (waveIdeas.length === 0) return null
|
||||
return (
|
||||
<div key={wave}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: wave === 1 ? '#fb923c' : wave === 2 ? '#60a5fa' : '#a78bfa' }}
|
||||
/>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t('brainstorm.waveBadge', { wave })} — {waveIdeas.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{waveIdeas.map(idea => (
|
||||
<button
|
||||
key={idea.id}
|
||||
onClick={() => { setSelectedIdeaId(idea.id) }}
|
||||
className={`w-full text-start p-4 rounded-xl border transition-all ${selectedIdeaId === idea.id ? 'border-brand-accent/40 bg-brand-accent/5' : 'border-border bg-white dark:bg-white/5 hover:border-foreground/20'}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground text-sm leading-snug">{idea.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{idea.description}</p>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
{idea.isStarred && <Star size={12} className="text-amber-400 fill-amber-400" />}
|
||||
{idea.status === 'converted' && <Check size={12} className="text-emerald-500" />}
|
||||
<ChevronRight size={14} className="text-muted-foreground/40" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : session ? (
|
||||
<WaveCanvas
|
||||
session={session}
|
||||
@@ -507,11 +645,16 @@ export function BrainstormPage() {
|
||||
<AnimatePresence>
|
||||
{selectedIdea && (
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
className="w-[400px] border-l border-border bg-white dark:bg-[#1A1A1A] flex flex-col z-20 shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
|
||||
initial={{ y: '100%', x: 0 }}
|
||||
animate={{ y: 0, x: 0 }}
|
||||
exit={{ y: '100%', x: 0 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed bottom-0 start-0 end-0 max-h-[80vh] z-30 md:relative md:bottom-auto md:start-auto md:end-auto md:max-h-none md:static md:w-[400px] border-t md:border-t-0 md:border-l border-border bg-white dark:bg-[#1A1A1A] flex flex-col rounded-t-2xl md:rounded-none shadow-[0_-20px_40px_rgba(0,0,0,0.08)] md:shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
|
||||
>
|
||||
{/* Drag handle for mobile */}
|
||||
<div className="md:hidden flex justify-center pt-3 pb-1 shrink-0">
|
||||
<div className="w-10 h-1 rounded-full bg-border" />
|
||||
</div>
|
||||
<div className="p-8 flex-1 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div
|
||||
@@ -531,10 +674,20 @@ export function BrainstormPage() {
|
||||
{t('brainstorm.noteCreated') || 'Note Created'}
|
||||
</span>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => setSelectedIdeaId(null)}
|
||||
className="p-2 hover:bg-foreground/5 rounded-full transition-colors text-muted-foreground"
|
||||
onClick={() => handleStarIdea(selectedIdea.id)}
|
||||
disabled={starIdea.isPending}
|
||||
className={`p-2 rounded-full transition-colors ${selectedIdea.isStarred ? 'text-amber-400 hover:text-amber-500' : 'text-muted-foreground hover:text-amber-400'}`}
|
||||
title={selectedIdea.isStarred ? (t('brainstorm.unstar') || 'Unstar') : (t('brainstorm.star') || 'Star')}
|
||||
>
|
||||
<Star size={18} className={selectedIdea.isStarred ? 'fill-amber-400' : ''} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelectedIdeaId(null)}
|
||||
className="p-2 hover:bg-foreground/5 rounded-full transition-colors text-muted-foreground"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -699,20 +852,30 @@ export function BrainstormPage() {
|
||||
<div className="w-px flex-1 bg-border/40" />
|
||||
<div className="flex flex-col gap-3 overflow-y-auto px-2">
|
||||
{sessions?.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setActiveSessionId(s.id)}
|
||||
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0 ${
|
||||
activeSessionId === s.id
|
||||
? 'bg-foreground text-background scale-110 shadow-lg'
|
||||
: (s as any)._owned === false
|
||||
? 'bg-brand-accent dark:bg-brand-accent/10 text-brand-accent hover:bg-blue-100 hover:text-brand-accent'
|
||||
: 'bg-white dark:bg-white/10 text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
|
||||
}`}
|
||||
title={s.seedIdea}
|
||||
>
|
||||
{s.seedIdea.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
<div key={s.id} className="relative group/session">
|
||||
<button
|
||||
onClick={() => setActiveSessionId(s.id)}
|
||||
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0 ${
|
||||
activeSessionId === s.id
|
||||
? 'bg-foreground text-background scale-110 shadow-lg'
|
||||
: (s as any)._owned === false
|
||||
? 'bg-brand-accent dark:bg-brand-accent/10 text-brand-accent hover:bg-blue-100 hover:text-brand-accent'
|
||||
: 'bg-white dark:bg-white/10 text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
|
||||
}`}
|
||||
title={s.seedIdea}
|
||||
>
|
||||
{s.seedIdea.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
{activeSessionId === s.id && (
|
||||
<button
|
||||
onClick={() => { setRenamingSession(s.id); setRenameInput(s.seedIdea) }}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-foreground/10 hover:bg-foreground/20 flex items-center justify-center opacity-0 group-hover/session:opacity-100 transition-opacity"
|
||||
title={t('brainstorm.renameSession') || 'Rename'}
|
||||
>
|
||||
<Edit2 size={8} className="text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-px h-12 bg-border/40" />
|
||||
@@ -730,6 +893,105 @@ export function BrainstormPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Summary modal */}
|
||||
<AnimatePresence>
|
||||
{summaryOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => setSummaryOpen(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 40, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 40, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-lg bg-white dark:bg-[#1A1A1A] rounded-2xl shadow-2xl p-8 relative"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles size={20} className="text-brand-accent" />
|
||||
<h3 className="font-serif text-xl font-medium">{t('brainstorm.exportModalTitle') || 'Session summary'}</h3>
|
||||
</div>
|
||||
<button onClick={() => setSummaryOpen(false)} className="p-1.5 hover:bg-foreground/5 rounded-full text-muted-foreground">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{summarize.isPending ? (
|
||||
<div className="flex items-center gap-3 text-muted-foreground italic py-8 justify-center">
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2].map(i => (
|
||||
<motion.div key={i} animate={{ scale: [1, 1.5, 1], opacity: [0.3, 1, 0.3] }} transition={{ duration: 1.5, repeat: Infinity, delay: i * 0.2 }} className="w-1.5 h-1.5 rounded-full bg-brand-accent" />
|
||||
))}
|
||||
</div>
|
||||
<span>{t('brainstorm.generating') || 'Generating synthesis...'}</span>
|
||||
</div>
|
||||
) : summaryText ? (
|
||||
<p className="text-foreground/80 leading-relaxed font-light text-base">{summaryText}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic text-sm text-center py-6">{t('brainstorm.summaryError') || 'Could not generate summary'}</p>
|
||||
)}
|
||||
<div className="mt-6 flex items-center gap-3 justify-between">
|
||||
{summaryText && (
|
||||
<button
|
||||
onClick={() => { setSummaryText(null); handleSummarize() }}
|
||||
className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('brainstorm.regenerateSummary') || 'Regenerate'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setSummaryOpen(false); handleExport() }}
|
||||
disabled={exportBrainstorm.isPending || summarize.isPending}
|
||||
className="ms-auto flex items-center gap-2 px-5 py-2.5 bg-foreground text-background rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Download size={13} />
|
||||
{t('brainstorm.exportAsNote') || 'Export as note'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Rename session modal */}
|
||||
<AnimatePresence>
|
||||
{renamingSession && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
||||
onClick={() => setRenamingSession(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-sm bg-white dark:bg-[#1A1A1A] rounded-2xl shadow-2xl p-6"
|
||||
>
|
||||
<h3 className="font-serif text-lg font-medium mb-4">{t('brainstorm.renameSession') || 'Rename session'}</h3>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={renameInput}
|
||||
onChange={e => setRenameInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameSession(renamingSession!); if (e.key === 'Escape') setRenamingSession(null) }}
|
||||
className="w-full border border-border rounded-xl px-4 py-3 text-sm bg-transparent outline-none focus:border-brand-accent/40 focus:ring-2 focus:ring-brand-accent/10"
|
||||
/>
|
||||
<div className="flex gap-2 mt-4 justify-end">
|
||||
<button onClick={() => setRenamingSession(null)} className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors">{t('common.cancel') || 'Cancel'}</button>
|
||||
<button onClick={() => handleRenameSession(renamingSession!)} disabled={renameSession.isPending} className="px-4 py-2 text-sm bg-foreground text-background rounded-xl disabled:opacity-50">{t('common.save') || 'Save'}</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{impactToast && (
|
||||
<motion.div
|
||||
|
||||
@@ -50,6 +50,7 @@ import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
|
||||
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
|
||||
import { scrapePageText } from '@/app/actions/scrape'
|
||||
import { PersonasPanel } from '@/components/personas-panel'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -210,11 +211,13 @@ export function ContextualAIChat({
|
||||
|
||||
// Generate slides / diagram state
|
||||
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
|
||||
const [generateProgress, setGenerateProgress] = useState(0)
|
||||
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
|
||||
const [customLangInput, setCustomLangInput] = useState('')
|
||||
// Generation options
|
||||
const [slideTheme, setSlideTheme] = useState('architectural_mono')
|
||||
const [slideTheme, setSlideTheme] = useState('auto')
|
||||
const [slideStyle, setSlideStyle] = useState('professional')
|
||||
const [slideTemplate, setSlideTemplate] = useState('auto')
|
||||
const [diagramType, setDiagramType] = useState('logic_flow')
|
||||
const [diagramStyle, setDiagramStyle] = useState('polished')
|
||||
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
|
||||
@@ -358,6 +361,24 @@ export function ContextualAIChat({
|
||||
// ── Generate slides / diagram ────────────────────────────────────────────────
|
||||
|
||||
const generatePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const generateProgressRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Fake progress bar: fast up to 30%, then slows, caps at 90% until done
|
||||
const startProgressBar = () => {
|
||||
setGenerateProgress(0)
|
||||
let current = 0
|
||||
if (generateProgressRef.current) clearInterval(generateProgressRef.current)
|
||||
generateProgressRef.current = setInterval(() => {
|
||||
current += current < 30 ? 3 : current < 60 ? 1.2 : current < 80 ? 0.4 : 0.1
|
||||
if (current >= 90) { clearInterval(generateProgressRef.current!); current = 90 }
|
||||
setGenerateProgress(Math.min(current, 90))
|
||||
}, 300)
|
||||
}
|
||||
const finishProgressBar = () => {
|
||||
if (generateProgressRef.current) clearInterval(generateProgressRef.current)
|
||||
setGenerateProgress(100)
|
||||
setTimeout(() => setGenerateProgress(0), 600)
|
||||
}
|
||||
|
||||
const handleGenerate = async (type: 'slides' | 'diagram') => {
|
||||
if (!noteId) {
|
||||
@@ -366,6 +387,7 @@ export function ContextualAIChat({
|
||||
}
|
||||
setGenerateLoading(type)
|
||||
setGenerateResult(null)
|
||||
startProgressBar()
|
||||
|
||||
const toastId = mToast.loading(
|
||||
type === 'slides' ? t('ai.generateSlidesLoading') : t('ai.generateDiagramLoading'),
|
||||
@@ -382,6 +404,7 @@ export function ContextualAIChat({
|
||||
theme: type === 'slides' ? slideTheme : diagramType,
|
||||
style: type === 'slides' ? slideStyle : diagramStyle,
|
||||
language: language === 'fr' ? 'French' : 'English',
|
||||
template: type === 'slides' ? slideTemplate : undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
@@ -394,7 +417,18 @@ export function ContextualAIChat({
|
||||
const { agentId } = data as { agentId: string }
|
||||
|
||||
if (generatePollRef.current) clearInterval(generatePollRef.current)
|
||||
let pollCount = 0
|
||||
const MAX_POLLS = 200 // 200 × 3s = 10 min safety timeout
|
||||
generatePollRef.current = setInterval(async () => {
|
||||
pollCount++
|
||||
if (pollCount > MAX_POLLS) {
|
||||
clearInterval(generatePollRef.current!)
|
||||
generatePollRef.current = null
|
||||
finishProgressBar()
|
||||
setGenerateLoading(null)
|
||||
mToast.error(t('ai.errorShort'), { id: toastId })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`)
|
||||
const poll = await pollRes.json()
|
||||
@@ -402,12 +436,14 @@ export function ContextualAIChat({
|
||||
if (poll.status === 'success') {
|
||||
clearInterval(generatePollRef.current!)
|
||||
generatePollRef.current = null
|
||||
finishProgressBar()
|
||||
setGenerateLoading(null)
|
||||
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
|
||||
mToast.success(t('ai.readyToast'), { id: toastId })
|
||||
} else if (poll.status === 'failure') {
|
||||
clearInterval(generatePollRef.current!)
|
||||
generatePollRef.current = null
|
||||
finishProgressBar()
|
||||
setGenerateLoading(null)
|
||||
mToast.error(poll.error || t('ai.errorShort'), { id: toastId })
|
||||
}
|
||||
@@ -415,6 +451,7 @@ export function ContextualAIChat({
|
||||
}, 3000)
|
||||
} catch {
|
||||
mToast.error(t('ai.errorShort'), { id: toastId })
|
||||
finishProgressBar()
|
||||
setGenerateLoading(null)
|
||||
}
|
||||
}
|
||||
@@ -731,7 +768,7 @@ export function ContextualAIChat({
|
||||
<div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
|
||||
<MessageSquare size={18} />
|
||||
</div>
|
||||
<p className="text-[11px] italic leading-relaxed px-12">{t('ai.welcomeMsg')}</p>
|
||||
<p className="text-[11px] italic leading-relaxed px-6">{t('ai.welcomeMsg')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -980,9 +1017,20 @@ export function ContextualAIChat({
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.theme')}</span>
|
||||
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="architectural_mono">{t('ai.generate.themeArchitecturalMono')}</option>
|
||||
<option value="vibrant_tech">{t('ai.generate.themeVibrantTech')}</option>
|
||||
<option value="minimal_silk">{t('ai.generate.themeMinimalSilk')}</option>
|
||||
<option value="auto">{t('ai.generate.themeAuto')}</option>
|
||||
<option value="Architectural SaaS">Architectural SaaS — SaaS, Produit</option>
|
||||
<option value="Midnight Cathedral">Midnight Cathedral — Finance, Luxe</option>
|
||||
<option value="Aurora Borealis">Aurora Borealis — Tech, IA</option>
|
||||
<option value="Tokyo Neon">Tokyo Neon — Gaming</option>
|
||||
<option value="Sunlit Gallery">Sunlit Gallery — Art, Culture</option>
|
||||
<option value="Clinical Precision">Clinical Precision — Santé, Science</option>
|
||||
<option value="Venture Pitch">Venture Pitch — Startup</option>
|
||||
<option value="Forest Floor">Forest Floor — ESG, Nature</option>
|
||||
<option value="Steel & Glass">Steel & Glass — Architecture</option>
|
||||
<option value="Cyberpunk Terminal">Cyberpunk Terminal — Dev, Cyber</option>
|
||||
<option value="Editorial Ink">Editorial Ink — Journalisme</option>
|
||||
<option value="Coastal Morning">Coastal Morning — Éducation</option>
|
||||
<option value="Paper Studio">Paper Studio — Research</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -994,10 +1042,36 @@ export function ContextualAIChat({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
|
||||
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
|
||||
<option value="auto">{t('ai.generate.templateAuto')}</option>
|
||||
<option value="board-update">{t('ai.generate.templateBoard')}</option>
|
||||
<option value="project-status">{t('ai.generate.templateProject')}</option>
|
||||
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
|
||||
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
|
||||
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
|
||||
</button>
|
||||
|
||||
{/* Progress bar — visible only during slides generation */}
|
||||
{generateLoading === 'slides' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[9px] text-foreground/40">
|
||||
<span className="uppercase tracking-widest">⏳ Génération en cours…</span>
|
||||
<span className="font-mono tabular-nums">{Math.round(generateProgress)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 rounded-full bg-foreground/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-brand-accent transition-all duration-300 ease-out"
|
||||
style={{ width: `${generateProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{generateResult?.type === 'slides' && generateResult.canvasId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -1018,35 +1092,15 @@ export function ContextualAIChat({
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/canvas?id=${generateResult.canvasId}`)
|
||||
const data = await res.json()
|
||||
if (!data.canvas?.data) throw new Error('No data')
|
||||
const parsed = JSON.parse(data.canvas.data)
|
||||
if (!parsed.base64) throw new Error('No base64')
|
||||
const byteChars = atob(parsed.base64)
|
||||
const bytes = new Uint8Array(byteChars.length)
|
||||
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i)
|
||||
const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = parsed.filename || `${data.canvas.name || 'presentation'}.pptx`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
mToast.error(t('ai.downloadFailedToast'))
|
||||
}
|
||||
}}
|
||||
<a
|
||||
href={`/lab?id=${generateResult.canvasId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-2.5 bg-brand-accent text-white rounded-lg text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity shadow-sm"
|
||||
>
|
||||
<Download size={13} />
|
||||
{t('ai.pptxDownloadButton')}
|
||||
</button>
|
||||
<Presentation size={13} />
|
||||
{t('ai.viewSlidesButton')}
|
||||
</a>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1129,6 +1183,8 @@ export function ContextualAIChat({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Personas IA ── */}
|
||||
<PersonasPanel noteTitle={noteTitle} noteContent={noteContent} />
|
||||
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||||
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 } from 'lucide-react'
|
||||
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 { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
@@ -81,7 +81,9 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const [showSortMenu, setShowSortMenu] = useState(false)
|
||||
const [showInlineSearch, setShowInlineSearch] = useState(false)
|
||||
const [inlineSearchQuery, setInlineSearchQuery] = useState('')
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const inlineSearchRef = useRef<HTMLInputElement>(null)
|
||||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const notesRef = useRef(notes)
|
||||
notesRef.current = notes
|
||||
const { refreshKey, triggerRefresh } = useNoteRefresh()
|
||||
@@ -98,13 +100,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-trigger disabled — user opens manually from AI panel
|
||||
// if (shouldSuggestLabels && suggestNotebookId) {
|
||||
// setAutoLabelOpen(true)
|
||||
// }
|
||||
}, [shouldSuggestLabels, suggestNotebookId])
|
||||
|
||||
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
|
||||
useEffect(() => {
|
||||
if (searchParams.get('forceList') === '1') {
|
||||
@@ -473,10 +468,18 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
|
||||
<div
|
||||
className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 dark:bg-background/90 backdrop-blur-md z-30"
|
||||
className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-10 md:pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 dark:bg-background/90 backdrop-blur-md z-30"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
{/* Hamburger mobile — ouvre la sidebar */}
|
||||
<button
|
||||
className="md:hidden p-2 -ms-2 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 self-start mt-1"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
{currentNotebook && notebookPath.length > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2 text-ink/60"
|
||||
@@ -491,7 +494,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pe-12">
|
||||
<h1 className="font-memento-serif text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight text-foreground leading-tight pe-4 sm:pe-12">
|
||||
{currentNotebook
|
||||
? currentNotebook.name
|
||||
: searchParams.get('shared') === '1'
|
||||
@@ -527,7 +530,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
{/* Inline search — toggles an input within the toolbar */}
|
||||
{showInlineSearch ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={14} className="text-muted-foreground shrink-0" />
|
||||
{isSearching ? (
|
||||
<div className="w-3.5 h-3.5 border border-muted-foreground/50 border-t-foreground rounded-full animate-spin shrink-0" />
|
||||
) : (
|
||||
<Search size={14} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<input
|
||||
ref={inlineSearchRef}
|
||||
autoFocus
|
||||
@@ -536,13 +543,18 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
onChange={e => {
|
||||
const q = e.target.value
|
||||
setInlineSearchQuery(q)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (q.trim()) {
|
||||
params.set('search', q)
|
||||
} else {
|
||||
params.delete('search')
|
||||
}
|
||||
router.push(`/home?${params.toString()}`)
|
||||
setIsSearching(true)
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
|
||||
searchDebounceRef.current = setTimeout(() => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (q.trim()) {
|
||||
params.set('search', q)
|
||||
} else {
|
||||
params.delete('search')
|
||||
}
|
||||
router.push(`/home?${params.toString()}`)
|
||||
setIsSearching(false)
|
||||
}, 300)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!inlineSearchQuery) {
|
||||
@@ -551,28 +563,32 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
|
||||
setShowInlineSearch(false)
|
||||
setInlineSearchQuery('')
|
||||
setIsSearching(false)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('search')
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
}}
|
||||
placeholder={t('search.placeholder')}
|
||||
className="w-48 bg-transparent border-b border-foreground/20 focus:border-foreground outline-none text-[13px] text-foreground placeholder:text-muted-foreground/50 py-0.5 transition-colors"
|
||||
className="w-36 sm:w-48 bg-transparent border-b border-foreground/20 focus:border-foreground outline-none text-[13px] text-foreground placeholder:text-muted-foreground/50 py-0.5 transition-colors"
|
||||
/>
|
||||
{inlineSearchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
|
||||
setShowInlineSearch(false)
|
||||
setInlineSearchQuery('')
|
||||
setIsSearching(false)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('search')
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="text-[11px]">×</span>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -714,7 +730,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-12 flex-1 pb-20">
|
||||
<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>
|
||||
) : (
|
||||
@@ -760,7 +776,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="px-12 py-6 border-t border-foreground/5 text-center mt-auto">
|
||||
<footer className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 border-t border-foreground/5 text-center mt-auto">
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-medium">
|
||||
Memento — {new Date().getFullYear()}
|
||||
</p>
|
||||
|
||||
@@ -4,17 +4,23 @@ import dynamic from 'next/dynamic'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Download, Presentation } from 'lucide-react'
|
||||
import { Download, Presentation, ExternalLink, Maximize2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
||||
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
|
||||
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
import type { PresentationSpec } from '@/lib/types/presentation'
|
||||
|
||||
const Excalidraw = dynamic(
|
||||
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const SlidesRenderer = dynamic(
|
||||
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
|
||||
{ ssr: false, loading: () => <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">Chargement des slides…</div> }
|
||||
)
|
||||
|
||||
interface CanvasBoardProps {
|
||||
initialData?: string
|
||||
canvasId?: string
|
||||
@@ -22,32 +28,37 @@ interface CanvasBoardProps {
|
||||
}
|
||||
|
||||
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string }
|
||||
type SlidesPayload = { type: 'slides'; html: string; title: string; theme?: string; style?: string; slideCount?: number; spec?: PresentationSpec }
|
||||
|
||||
function parseCanvasScene(initialData?: string): {
|
||||
slides: SlidesPayload | null
|
||||
pptx: PptxPayload | null
|
||||
elements: readonly ExcalidrawElement[]
|
||||
files: BinaryFiles
|
||||
} {
|
||||
if (!initialData) {
|
||||
return { pptx: null, elements: [], files: {} }
|
||||
return { slides: null, pptx: null, elements: [], files: {} }
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(initialData)
|
||||
if (parsed && parsed.type === 'slides' && parsed.html) {
|
||||
return { slides: parsed as SlidesPayload, pptx: null, elements: [], files: {} }
|
||||
}
|
||||
if (parsed && parsed.type === 'pptx' && parsed.base64) {
|
||||
return { pptx: parsed as PptxPayload, elements: [], files: {} }
|
||||
return { slides: null, pptx: parsed as PptxPayload, elements: [], files: {} }
|
||||
}
|
||||
if (parsed && Array.isArray(parsed)) {
|
||||
return { pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
|
||||
return { slides: null, pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
|
||||
}
|
||||
if (parsed && parsed.elements) {
|
||||
const files: BinaryFiles =
|
||||
parsed.files && typeof parsed.files === 'object' ? parsed.files : {}
|
||||
return { pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
|
||||
return { slides: null, pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CanvasBoard] Failed to parse canvas data:', e)
|
||||
}
|
||||
return { pptx: null, elements: [], files: {} }
|
||||
return { slides: null, pptx: null, elements: [], files: {} }
|
||||
}
|
||||
|
||||
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
||||
@@ -105,6 +116,153 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const [currentSlide, setCurrentSlide] = useState(1)
|
||||
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
|
||||
const totalSlides = data.slideCount || 1
|
||||
|
||||
// Hide the global AI floating button while slides are displayed
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
|
||||
return () => {
|
||||
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Listen for slide-change events from the iframe via postMessage
|
||||
useEffect(() => {
|
||||
const handler = (e: MessageEvent) => {
|
||||
if (e.data?.type === 'slideChange' && typeof e.data.current === 'number') {
|
||||
setCurrentSlide(e.data.current)
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handler)
|
||||
return () => window.removeEventListener('message', handler)
|
||||
}, [])
|
||||
|
||||
const navigate = (dir: number) => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return
|
||||
// Direct contentWindow access (works with allow-same-origin)
|
||||
try {
|
||||
const win = iframe.contentWindow as any
|
||||
if (typeof win?.changeSlide === 'function') {
|
||||
win.changeSlide(dir)
|
||||
return
|
||||
}
|
||||
} catch (_) {}
|
||||
// Fallback: postMessage
|
||||
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
|
||||
}
|
||||
|
||||
const openFullscreen = () => {
|
||||
const blob = new Blob([data.html], { type: 'text/html' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10000)
|
||||
}
|
||||
|
||||
const downloadHtml = () => {
|
||||
const blob = new Blob([data.html], { type: 'text/html' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${name.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'presentation'}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col bg-zinc-950">
|
||||
{/* Toolbar */}
|
||||
<div className="shrink-0 h-11 flex items-center justify-between px-4 bg-zinc-900/80 border-b border-white/10 backdrop-blur-sm z-10">
|
||||
<div className="flex items-center gap-2.5 text-white/70 min-w-0">
|
||||
<Presentation className="w-4 h-4 shrink-0 text-white/50" />
|
||||
<span className="text-sm font-medium truncate">{name}</span>
|
||||
{totalSlides > 1 && (
|
||||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
|
||||
{currentSlide} / {totalSlides}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canvasId && (
|
||||
<a
|
||||
href={`/api/canvas/slides/pptx?id=${canvasId}`}
|
||||
download
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
||||
title="Exporter en PowerPoint"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Export PPTX
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={downloadHtml}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
||||
title="Télécharger le HTML"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Export HTML
|
||||
</button>
|
||||
<button
|
||||
onClick={openFullscreen}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
||||
title="Ouvrir en plein écran"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
Plein écran
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Slides: React renderer (legacy spec) or iframe (new HTML) */}
|
||||
<div className="flex-1 relative overflow-hidden group">
|
||||
{data.spec ? (
|
||||
<SlidesRenderer spec={data.spec} />
|
||||
) : (
|
||||
<>
|
||||
{/* Loading overlay — visible until iframe fires onLoad */}
|
||||
{!isLoaded && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
|
||||
<span className="text-xs text-white/30">Chargement de la présentation…</span>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
title={name}
|
||||
allow="fullscreen"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
{/* Prev button */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Slide précédent"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Next button */}
|
||||
<button
|
||||
onClick={() => navigate(1)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Slide suivant"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
||||
@@ -173,6 +331,10 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
if (scene.slides) {
|
||||
return <SlidesViewer data={scene.slides} name={name} canvasId={localId} />
|
||||
}
|
||||
|
||||
if (scene.pptx) {
|
||||
return <PptxViewer data={scene.pptx} name={name} />
|
||||
}
|
||||
|
||||
39
memento-note/components/lab/mermaid-diagram.tsx
Normal file
39
memento-note/components/lab/mermaid-diagram.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
let counter = 0
|
||||
|
||||
export function MermaidDiagram({ chart, isDark }: { chart: string; isDark?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !chart.trim()) return
|
||||
const id = `mermaid-slide-${++counter}`
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDark ? 'dark' : 'default',
|
||||
themeVariables: isDark
|
||||
? { primaryColor: '#A47148', primaryTextColor: '#F9F8F6', lineColor: '#D4A373', secondaryColor: '#2A2A2A', tertiaryColor: '#1C1C1C' }
|
||||
: { primaryColor: '#A47148', primaryTextColor: '#1C1C1C', lineColor: '#D4A373' },
|
||||
securityLevel: 'loose',
|
||||
})
|
||||
mermaid.render(id, chart)
|
||||
.then(({ svg }) => {
|
||||
if (ref.current) ref.current.innerHTML = svg
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[MermaidDiagram] render error:', err)
|
||||
if (ref.current) ref.current.innerHTML = `<pre style="color:rgba(255,255,255,0.4);font-size:12px">${chart}</pre>`
|
||||
})
|
||||
}, [chart, isDark])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex justify-center items-center w-full h-full overflow-hidden"
|
||||
style={{ minHeight: 200 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
665
memento-note/components/lab/slides-renderer.tsx
Normal file
665
memento-note/components/lab/slides-renderer.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { PresentationSpec, SlideSpec, Palette } from '@/lib/types/presentation'
|
||||
import { resolvePalette, resolveRadius } from '@/lib/ai/tools/slides-palettes'
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, AreaChart, Area,
|
||||
PieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||
FunnelChart, Funnel, LabelList,
|
||||
} from 'recharts'
|
||||
|
||||
const MermaidDiagram = dynamic(
|
||||
() => import('./mermaid-diagram').then(m => ({ default: m.MermaidDiagram })),
|
||||
{ ssr: false, loading: () => <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>Chargement…</div> }
|
||||
)
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
const CHART_COLORS = ['#6366f1', '#22d3ee', '#f59e0b', '#ef4444', '#10b981', '#a78bfa', '#fb923c', '#14b8a6']
|
||||
const TT_STYLE: CSSProperties = { background: '#1C1C1C', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, fontSize: 12 }
|
||||
const TICK = { fill: 'rgba(255,255,255,0.55)', fontSize: 11 }
|
||||
const GRID_STROKE = 'rgba(255,255,255,0.07)'
|
||||
|
||||
// ── Chart ──────────────────────────────────────────────────────────────────────
|
||||
function SlideChart({ slide }: { slide: SlideSpec }) {
|
||||
const { chart } = slide
|
||||
if (!chart?.data?.length) return null
|
||||
|
||||
const colors = chart.colors ?? CHART_COLORS
|
||||
const xKey = chart.xKey ?? 'name'
|
||||
const yKeys = chart.yKeys ?? Object.keys(chart.data[0]).filter(k => k !== xKey)
|
||||
const legend = chart.showLegend !== false && yKeys.length > 1
|
||||
const legSt = { fontSize: 12, color: 'rgba(255,255,255,0.6)' }
|
||||
|
||||
switch (chart.type) {
|
||||
case 'bar':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
|
||||
{legend && <Legend wrapperStyle={legSt} />}
|
||||
{yKeys.map((k, i) => <Bar key={k} dataKey={k} fill={colors[i % colors.length]} radius={[4, 4, 0, 0]} maxBarSize={56} />)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'line':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
{legend && <Legend wrapperStyle={legSt} />}
|
||||
{yKeys.map((k, i) => <Line key={k} type="monotone" dataKey={k} stroke={colors[i % colors.length]} strokeWidth={2.5} dot={{ r: 4, strokeWidth: 0 }} activeDot={{ r: 6 }} />)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'area':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
{legend && <Legend wrapperStyle={legSt} />}
|
||||
{yKeys.map((k, i) => <Area key={k} type="monotone" dataKey={k} stroke={colors[i % colors.length]} fill={`${colors[i % colors.length]}28`} strokeWidth={2.5} />)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'pie':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={chart.data} dataKey={yKeys[0] ?? 'value'} nameKey={xKey} cx="50%" cy="50%" outerRadius="70%" innerRadius="35%" paddingAngle={2}>
|
||||
{chart.data.map((_, i) => <Cell key={i} fill={colors[i % colors.length]} stroke="transparent" />)}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
{chart.showLegend !== false && <Legend wrapperStyle={legSt} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'radar':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={chart.data} cx="50%" cy="50%">
|
||||
<PolarGrid stroke="rgba(255,255,255,0.1)" />
|
||||
<PolarAngleAxis dataKey={xKey} tick={{ fill: 'rgba(255,255,255,0.65)', fontSize: 11 }} />
|
||||
{yKeys.map((k, i) => <Radar key={k} name={k} dataKey={k} stroke={colors[i % colors.length]} fill={colors[i % colors.length]} fillOpacity={0.15} />)}
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
{legend && <Legend wrapperStyle={legSt} />}
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'stacked-bar':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
|
||||
<Legend wrapperStyle={legSt} />
|
||||
{yKeys.map((k, i) => <Bar key={k} dataKey={k} stackId="a" fill={colors[i % colors.length]} radius={i === yKeys.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]} />)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'combo': {
|
||||
const lineKeys = chart.lineKeys ?? (yKeys.length > 1 ? [yKeys[yKeys.length - 1]] : [])
|
||||
const barKeys = yKeys.filter(k => !lineKeys.includes(k))
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
|
||||
<Legend wrapperStyle={legSt} />
|
||||
{barKeys.map((k, i) => <Bar key={k} dataKey={k} fill={colors[i % colors.length]} radius={[4, 4, 0, 0]} maxBarSize={56} />)}
|
||||
{lineKeys.map((k, i) => <Line key={k} type="monotone" dataKey={k} stroke={colors[(barKeys.length + i) % colors.length]} strokeWidth={2.5} dot={{ r: 4, strokeWidth: 0 }} />)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
case 'funnel':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<FunnelChart>
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
<Funnel dataKey={yKeys[0] ?? 'value'} data={chart.data.map((d, i) => ({ ...d, fill: colors[i % colors.length] }))} isAnimationActive>
|
||||
<LabelList position="center" fill="#fff" fontSize={12} fontWeight={700} dataKey={xKey} />
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'gauge': {
|
||||
const value = chart.gaugeValue ?? (chart.data[0]?.[yKeys[0]] as number) ?? 0
|
||||
const label = chart.gaugeLabel ?? yKeys[0] ?? ''
|
||||
const max = 100
|
||||
const pct = Math.min(Math.max(value / max, 0), 1)
|
||||
const color = pct > 0.7 ? '#10b981' : pct > 0.4 ? '#f59e0b' : '#ef4444'
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 200 120" style={{ width: '60%', maxHeight: '70%' }}>
|
||||
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="16" strokeLinecap="round" />
|
||||
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke={color} strokeWidth="16" strokeLinecap="round"
|
||||
strokeDasharray={`${pct * 251.2} 251.2`} />
|
||||
<text x="100" y="90" textAnchor="middle" fill="#fff" fontSize="28" fontWeight="800">{value}%</text>
|
||||
<text x="100" y="112" textAnchor="middle" fill="rgba(255,255,255,0.5)" fontSize="11">{label}</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'waterfall': {
|
||||
// Render waterfall as bar chart with cumulative values + special coloring
|
||||
let cumulative = 0
|
||||
const waterfallData = chart.data.map((d, i) => {
|
||||
const val = (d[yKeys[0]] as number) ?? 0
|
||||
const start = cumulative
|
||||
cumulative += val
|
||||
return { ...d, __start: start, __end: cumulative, __delta: val, __isPositive: val >= 0, __isTotal: i === chart.data.length - 1 }
|
||||
})
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={waterfallData} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
<Bar dataKey="__start" stackId="w" fill="transparent" />
|
||||
<Bar dataKey="__delta" stackId="w" radius={[4, 4, 0, 0]} maxBarSize={56}>
|
||||
{waterfallData.map((d, i) => <Cell key={i} fill={d.__isTotal ? colors[2] : d.__isPositive ? colors[4] : colors[3]} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
case 'treemap': {
|
||||
// Render treemap as proportional boxes
|
||||
const total = chart.data.reduce((s, d) => s + ((d[yKeys[0]] as number) ?? 0), 0)
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexWrap: 'wrap', gap: 4, padding: 8, alignContent: 'flex-start' }}>
|
||||
{chart.data.map((d, i) => {
|
||||
const val = (d[yKeys[0]] as number) ?? 0
|
||||
const pct = total > 0 ? (val / total) * 100 : 10
|
||||
return (
|
||||
<div key={i} style={{ width: `${Math.max(pct * 2.5, 12)}%`, height: `${Math.max(pct * 1.5, 20)}%`, minWidth: 60, minHeight: 40, background: colors[i % colors.length], borderRadius: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 8 }}>
|
||||
<span style={{ color: '#fff', fontSize: 11, fontWeight: 700, textAlign: 'center' }}>{d[xKey] as string}</span>
|
||||
<span style={{ color: 'rgba(255,255,255,0.7)', fontSize: 10 }}>{val}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slide content renderer (per layout) ───────────────────────────────────────
|
||||
function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; index: number; palette: Palette; radius: string }) {
|
||||
const layout = slide.layout ?? (index === 0 ? 'title' : 'content')
|
||||
const isDark = palette.isDark
|
||||
const text = isDark ? '#f1f5f9' : '#1a1a1a'
|
||||
const muted = isDark ? 'rgba(241,245,249,0.55)' : 'rgba(0,0,0,0.55)'
|
||||
const cardBg = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
|
||||
const cardBorder = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'
|
||||
const accentBar: CSSProperties = { width: 48, height: 4, background: palette.accent, borderRadius: 2, marginBottom: 24, flexShrink: 0 }
|
||||
|
||||
switch (layout) {
|
||||
// ── TITLE ────────────────────────────────────────────────────────────
|
||||
case 'title':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: `linear-gradient(140deg, ${palette.primary} 0%, ${isDark ? palette.bg : palette.secondary} 100%)`, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', padding: '72px 80px', position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: -100, right: -80, width: 500, height: 500, borderRadius: '50%', background: 'rgba(255,255,255,0.05)', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', bottom: -120, right: -20, width: 320, height: 320, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.06)', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', top: 56, left: 80, fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase' as const, color: 'rgba(255,255,255,0.4)' }}>Présentation</div>
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ width: 52, height: 5, background: palette.accent, borderRadius: 3, marginBottom: 20, opacity: 0.9 }} />
|
||||
<h1 style={{ color: '#fff', fontSize: 56, fontWeight: 800, lineHeight: 1.05, letterSpacing: '-0.04em', margin: 0, maxWidth: 900 }}>{slide.title}</h1>
|
||||
{slide.subtitle && <p style={{ color: 'rgba(255,255,255,0.6)', fontSize: 20, fontWeight: 300, marginTop: 16, maxWidth: 640, lineHeight: 1.5 }}>{slide.subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── SECTION DIVIDER ─────────────────────────────────────────────────
|
||||
case 'section':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: isDark ? palette.primary : palette.primary, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '52px 80px', position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', right: 40, bottom: -30, fontSize: 200, fontWeight: 900, color: 'rgba(255,255,255,0.05)', lineHeight: 1, letterSpacing: '-0.06em', userSelect: 'none' as const, pointerEvents: 'none' as const }}>
|
||||
{slide.content[0] ?? String(index).padStart(2, '0')}
|
||||
</div>
|
||||
<span style={{ display: 'inline-block', background: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)', fontSize: 12, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase' as const, padding: '6px 16px', borderRadius: 100, marginBottom: 20, alignSelf: 'flex-start' }}>
|
||||
Section {slide.content[0] ?? String(index).padStart(2, '0')}
|
||||
</span>
|
||||
<h2 style={{ color: '#fff', fontSize: 44, fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 1.05, margin: 0, maxWidth: 780 }}>{slide.title}</h2>
|
||||
{slide.subtitle && <p style={{ color: 'rgba(255,255,255,0.55)', fontSize: 18, marginTop: 12 }}>{slide.subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── QUOTE ─────────────────────────────────────────────────────────────
|
||||
case 'quote':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: isDark ? '#0d1117' : '#1a1a2e', display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '52px 80px', position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', right: -10, top: -40, fontSize: 400, fontWeight: 900, color: 'rgba(255,255,255,0.03)', lineHeight: 0.7, fontFamily: 'Georgia, serif', userSelect: 'none' as const, pointerEvents: 'none' as const }}>"</div>
|
||||
<div style={{ fontSize: 80, color: palette.accent, lineHeight: 0.6, fontFamily: 'Georgia, serif', marginBottom: 16, opacity: 0.7 }}>"</div>
|
||||
<blockquote style={{ color: '#fff', fontSize: 28, fontWeight: 600, lineHeight: 1.4, letterSpacing: '-0.02em', margin: 0, maxWidth: 860, fontStyle: 'italic' }}>{slide.title}</blockquote>
|
||||
{slide.subtitle && <cite style={{ display: 'block', color: 'rgba(255,255,255,0.45)', fontSize: 14, fontStyle: 'normal', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, marginTop: 28 }}>— {slide.subtitle}</cite>}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── TOC ──────────────────────────────────────────────────────────────
|
||||
case 'toc':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title || 'Sommaire'}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{slide.content.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '12px 18px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, letterSpacing: '0.08em', color: palette.accent, minWidth: 28 }}>{String(i + 1).padStart(2, '0')}</span>
|
||||
<span style={{ fontSize: 16, fontWeight: 500, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── CONTENT (bullets) ────────────────────────────────────────────────
|
||||
case 'content':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, justifyContent: 'center' }}>
|
||||
{slide.content.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, padding: '4px 0' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: palette.accent, marginTop: 8, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 18, lineHeight: 1.6, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── TWO COLUMN ──────────────────────────────────────────────────────
|
||||
case 'two-column': {
|
||||
const mid = Math.ceil(slide.content.length / 2)
|
||||
const left = slide.content.slice(0, mid)
|
||||
const right = slide.content.slice(mid)
|
||||
const heads = (slide.subtitle ?? '/').split('/')
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 28, flex: 1, alignContent: 'start' }}>
|
||||
{[{ items: left, head: heads[0]?.trim() || '' }, { items: right, head: heads[1]?.trim() || '' }].map(({ items, head }, col) => (
|
||||
<div key={col} style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '20px 24px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
{head && <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase' as const, color: palette.accent, marginBottom: 12 }}>{head}</span>}
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '6px 0' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: palette.accent, marginTop: 7, flexShrink: 0, opacity: 0.7 }} />
|
||||
<span style={{ fontSize: 15, lineHeight: 1.55, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CARDS ────────────────────────────────────────────────────────────
|
||||
case 'cards': {
|
||||
const items = slide.content.slice(0, 6)
|
||||
const cols = items.length <= 2 ? 2 : items.length <= 3 ? 3 : items.length === 4 ? 2 : 3
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: 16, flex: 1, alignContent: 'center' }}>
|
||||
{items.map((item, i) => {
|
||||
const sep = item.search(/[:\u2014\u2013\-]/)
|
||||
const head = sep > 0 ? item.slice(0, sep).trim() : ''
|
||||
const body = sep > 0 ? item.slice(sep + 1).trim() : item
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '20px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}`, position: 'relative', overflow: 'hidden' }}>
|
||||
<span style={{ position: 'absolute', top: 10, right: 14, fontWeight: 900, fontSize: 28, color: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', lineHeight: 1 }}>{String(i + 1).padStart(2, '0')}</span>
|
||||
<div style={{ width: 24, height: 3, background: palette.accent, borderRadius: 2 }} />
|
||||
{head && <p style={{ margin: 0, fontSize: 15, fontWeight: 700, letterSpacing: '0.02em', color: text }}>{head}</p>}
|
||||
<p style={{ margin: 0, fontSize: 14, lineHeight: 1.55, color: muted }}>{body}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── STATS ────────────────────────────────────────────────────────────
|
||||
case 'stats': {
|
||||
const items = slide.content.slice(0, 4)
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${items.length}, 1fr)`, gap: 20, flex: 1, alignContent: 'center' }}>
|
||||
{items.map((item, i) => {
|
||||
const parts = item.split(/[-–—:]/)
|
||||
const val = parts[0]?.trim() ?? item
|
||||
const lbl = parts.slice(1).join(' ').trim()
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'flex-start', padding: '28px 24px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
<span style={{ fontSize: 42, fontWeight: 900, letterSpacing: '-0.04em', color: palette.accent, lineHeight: 1 }}>{val}</span>
|
||||
<div style={{ width: 32, height: 3, background: palette.accent, borderRadius: 2, margin: '12px 0 10px', opacity: 0.6 }} />
|
||||
{lbl && <span style={{ fontSize: 13, fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: muted }}>{lbl}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── SUMMARY ──────────────────────────────────────────────────────────
|
||||
case 'summary':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title || 'En résumé'}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, justifyContent: 'center' }}>
|
||||
{slide.content.slice(0, 6).map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '14px 20px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
<div style={{ width: 26, height: 26, minWidth: 26, background: palette.accent, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: '#fff', fontWeight: 900 }}>✓</div>
|
||||
<span style={{ fontSize: 16, lineHeight: 1.45, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── CHART ────────────────────────────────────────────────────────────
|
||||
case 'chart':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', background: isDark ? palette.bg : '#111827', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.04em', color: '#fff' }}>{slide.title}</h2>
|
||||
{slide.subtitle && <p style={{ margin: '6px 0 0', fontSize: 15, color: 'rgba(255,255,255,0.5)' }}>{slide.subtitle}</p>}
|
||||
<div style={{ flex: 1, marginTop: 24 }}>
|
||||
<SlideChart slide={slide} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── DIAGRAM ──────────────────────────────────────────────────────────
|
||||
case 'diagram':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', background: isDark ? palette.bg : '#111827', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.04em', color: '#fff' }}>{slide.title}</h2>
|
||||
{slide.subtitle && <p style={{ margin: '6px 0 0', fontSize: 15, color: 'rgba(255,255,255,0.5)' }}>{slide.subtitle}</p>}
|
||||
<div style={{ flex: 1, marginTop: 20, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{slide.mermaid ? <MermaidDiagram chart={slide.mermaid} isDark={true} /> : <p style={{ color: 'rgba(255,255,255,0.3)' }}>No diagram</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── IMAGE ────────────────────────────────────────────────────────────
|
||||
case 'image':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{slide.imageUrl
|
||||
? <img src={slide.imageUrl} alt={slide.title} style={{ maxHeight: '70%', maxWidth: '90%', borderRadius: radius, objectFit: 'contain' }} />
|
||||
: <div style={{ color: muted, fontSize: 14 }}>No image</div>
|
||||
}
|
||||
</div>
|
||||
{slide.content[0] && <p style={{ margin: '12px 0 0', fontSize: 13, textAlign: 'center', color: muted }}>{slide.content[0]}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── TIMELINE ────────────────────────────────────────────────────────
|
||||
case 'timeline': {
|
||||
const items = slide.content.slice(0, 8)
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', paddingLeft: 28 }}>
|
||||
<div style={{ position: 'absolute', left: 11, top: 0, bottom: 0, width: 2, background: `linear-gradient(to bottom, ${palette.accent}, transparent)` }} />
|
||||
{items.map((item, i) => {
|
||||
const sep = item.search(/[:\u2014\u2013]/)
|
||||
const date = sep > 0 ? item.slice(0, sep).trim() : ''
|
||||
const desc = sep > 0 ? item.slice(sep + 1).trim() : item
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, marginBottom: 16, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', left: -22, top: 6, width: 12, height: 12, borderRadius: '50%', background: palette.accent, border: `3px solid ${isDark ? palette.bg : '#fff'}`, zIndex: 1 }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingLeft: 8 }}>
|
||||
{date && <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: palette.accent }}>{date}</span>}
|
||||
<span style={{ fontSize: 15, lineHeight: 1.5, color: text }}>{desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── KPI DASHBOARD ───────────────────────────────────────────────────
|
||||
case 'kpi-dashboard': {
|
||||
const items = slide.content.slice(0, 6)
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${items.length <= 3 ? items.length : 3}, 1fr)`, gap: 14, flex: 1, alignContent: 'center' }}>
|
||||
{items.map((item, i) => {
|
||||
// Format: "value | label | trend" or "value — label (trend)"
|
||||
const parts = item.split(/[|]/).map(s => s.trim())
|
||||
const val = parts[0] ?? item
|
||||
const lbl = parts[1] ?? ''
|
||||
const trend = parts[2] ?? ''
|
||||
const isUp = trend.includes('↑') || trend.includes('+')
|
||||
const isDown = trend.includes('↓') || trend.includes('-')
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '20px 18px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
<span style={{ fontSize: 32, fontWeight: 900, letterSpacing: '-0.03em', color: palette.accent, lineHeight: 1 }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 12, fontWeight: 600, color: muted, letterSpacing: '0.05em', textTransform: 'uppercase' as const }}>{lbl}</span>}
|
||||
{trend && <span style={{ fontSize: 12, fontWeight: 700, color: isUp ? '#10b981' : isDown ? '#ef4444' : muted }}>{trend}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── DATA TABLE ──────────────────────────────────────────────────────
|
||||
case 'data-table': {
|
||||
const headers = slide.tableHeaders ?? (slide.content[0]?.split('|').map(s => s.trim()) ?? [])
|
||||
const rows = slide.tableRows ?? slide.content.slice(headers === slide.tableHeaders ? 0 : 1).map(row => row.split('|').map(s => s.trim()))
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
{headers.length > 0 && (
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} style={{ padding: '10px 14px', borderBottom: `2px solid ${palette.accent}`, textAlign: 'left', fontWeight: 700, fontSize: 11, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: palette.accent }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} style={{ background: ri % 2 === 0 ? 'transparent' : cardBg }}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} style={{ padding: '10px 14px', borderBottom: `1px solid ${cardBorder}`, color: text, lineHeight: 1.4 }}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── DEFAULT ──────────────────────────────────────────────────────────
|
||||
default:
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, justifyContent: 'center' }}>
|
||||
{slide.content.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, padding: '4px 0' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: palette.accent, marginTop: 8, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 18, lineHeight: 1.6, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// MAIN RENDERER — Pure React + CSS transitions (no Reveal.js dependency)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
export interface SlidesRendererProps {
|
||||
spec: PresentationSpec
|
||||
}
|
||||
|
||||
export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
||||
const [current, setCurrent] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const total = spec.slides.length
|
||||
const { palette } = resolvePalette(spec)
|
||||
const radius = resolveRadius(spec.style)
|
||||
const isDark = palette.isDark
|
||||
|
||||
const next = useCallback(() => setCurrent(c => Math.min(c + 1, total - 1)), [total])
|
||||
const prev = useCallback(() => setCurrent(c => Math.max(c - 1, 0)), [])
|
||||
|
||||
// Keyboard + touch navigation
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { e.preventDefault(); next() }
|
||||
if (e.key === 'ArrowLeft' || e.key === 'Backspace') { e.preventDefault(); prev() }
|
||||
}
|
||||
|
||||
let startX = 0
|
||||
const onTouchStart = (e: TouchEvent) => { startX = e.touches[0].clientX }
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
const diff = e.changedTouches[0].clientX - startX
|
||||
if (Math.abs(diff) > 50) { diff < 0 ? next() : prev() }
|
||||
}
|
||||
|
||||
el.addEventListener('keydown', onKey)
|
||||
el.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||
el.addEventListener('touchend', onTouchEnd, { passive: true })
|
||||
el.focus()
|
||||
return () => {
|
||||
el.removeEventListener('keydown', onKey)
|
||||
el.removeEventListener('touchstart', onTouchStart)
|
||||
el.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
}, [next, prev])
|
||||
|
||||
// Nav button style
|
||||
const navBtn = (isLeft: boolean): CSSProperties => ({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
...(isLeft ? { left: 16 } : { right: 16 }),
|
||||
zIndex: 50,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: '50%',
|
||||
border: `1px solid ${isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)'}`,
|
||||
background: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.85)',
|
||||
color: isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.6)',
|
||||
fontSize: 20,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(8px)',
|
||||
transition: 'all 0.15s',
|
||||
userSelect: 'none' as const,
|
||||
padding: 0,
|
||||
boxShadow: isDark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: palette.bg,
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
overflow: 'hidden',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Slides */}
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
{spec.slides.map((slide, i) => {
|
||||
const offset = i - current
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transform: `translateX(${offset * 100}%)`,
|
||||
transition: 'transform 0.45s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<SlideContent slide={slide} index={i} palette={palette} radius={radius} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
{current > 0 && (
|
||||
<button style={navBtn(true)} onClick={prev} aria-label="Précédent">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
{current < total - 1 && (
|
||||
<button style={navBtn(false)} onClick={next} aria-label="Suivant">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 6 15 12 9 18" /></svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 3, background: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', zIndex: 40 }}>
|
||||
<div style={{ height: '100%', width: `${((current + 1) / total) * 100}%`, background: palette.accent, transition: 'width 0.4s ease', borderRadius: '0 2px 2px 0' }} />
|
||||
</div>
|
||||
|
||||
{/* Slide counter */}
|
||||
<div style={{ position: 'absolute', bottom: 12, right: 16, zIndex: 40, fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)' }}>
|
||||
{current + 1} / {total}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { hi } from 'date-fns/locale/hi'
|
||||
import { nl } from 'date-fns/locale/nl'
|
||||
import { pl } from 'date-fns/locale/pl'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
import { NoteImages } from './note-images'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
import { NoteActions } from './note-actions'
|
||||
@@ -196,11 +197,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
|
||||
const sanitizedHtml = useMemo(() => {
|
||||
if (note.type !== 'richtext' || !note.content) return ''
|
||||
if (typeof window !== 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require('isomorphic-dompurify').sanitize(note.content)
|
||||
}
|
||||
return note.content
|
||||
return DOMPurify.sanitize(note.content)
|
||||
}, [note.type, note.content])
|
||||
|
||||
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { NoteAttachments } from '@/components/note-attachments'
|
||||
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useState } from 'react'
|
||||
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
|
||||
|
||||
interface NoteEditorFullPageProps {
|
||||
onClose: () => void
|
||||
@@ -114,6 +115,9 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
onCountChange={setAttachmentsCount}
|
||||
triggerUpload={uploadTrigger}
|
||||
/>
|
||||
|
||||
{/* WikiLinks backlinks */}
|
||||
<WikilinksBacklinksPanel noteId={note.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
|
||||
if (mode === 'fullPage') {
|
||||
return (
|
||||
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
|
||||
<div className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 md:py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
||||
|
||||
354
memento-note/components/note-graph-view.tsx
Normal file
354
memento-note/components/note-graph-view.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Loader2, Network, Filter, X, ExternalLink, Maximize2 } from 'lucide-react'
|
||||
|
||||
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
|
||||
|
||||
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; content: string; createdAt: string }
|
||||
|
||||
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
|
||||
|
||||
export function NoteGraphView() {
|
||||
const router = useRouter()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const graphRef = useRef<any>(null)
|
||||
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)
|
||||
|
||||
// ─── 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('Erreur réseau'); 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))]
|
||||
ids.forEach((id, i) => map.set(id, PALETTE[i % PALETTE.length]))
|
||||
return map
|
||||
}, [rawData])
|
||||
|
||||
// ─── Graph data ───────────────────────────────────────────────────────────
|
||||
const graphData = useMemo(() => {
|
||||
if (!rawData) return { nodes: [], links: [] }
|
||||
const filtered = searchFilter.trim()
|
||||
? rawData.nodes.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
: rawData.nodes
|
||||
const filteredIds = new Set(filtered.map(n => n.id))
|
||||
return {
|
||||
nodes: filtered.map(n => ({
|
||||
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,
|
||||
})),
|
||||
links: rawData.edges
|
||||
.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
color: e.type === 'title_mention' ? '#f59e0b' : e.type === 'shared_label' ? '#6366f1' : '#e2e8f0',
|
||||
width: e.type === 'title_mention' ? 2 : e.type === 'shared_label' ? 1.5 : 0.6,
|
||||
})),
|
||||
}
|
||||
}, [rawData, searchFilter, colorMap])
|
||||
|
||||
// ─── 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) {
|
||||
// Double-click → zoom
|
||||
lastClickRef.current = null
|
||||
graphRef.current?.centerAt(node.x, node.y, 600)
|
||||
graphRef.current?.zoom(3, 600)
|
||||
return
|
||||
}
|
||||
lastClickRef.current = { id: node.id, time: now }
|
||||
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
|
||||
}, [rawData])
|
||||
|
||||
const handleZoomToFit = useCallback(() => {
|
||||
graphRef.current?.zoomToFit(400, 50)
|
||||
}, [])
|
||||
|
||||
const plainText = (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)
|
||||
|
||||
// ─── 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 bg-[#FAFAF9]">
|
||||
{/* Header */}
|
||||
<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">Vue en graphe</h1>
|
||||
{rawData && (
|
||||
<span className="text-[10px] text-concrete/50 font-medium">
|
||||
{rawData.nodes.length} notes · {rawData.edges.length} liens
|
||||
</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="Filtrer…"
|
||||
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"
|
||||
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) => {
|
||||
if (globalScale < 0.7) return
|
||||
const name: string = node.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(node.val ?? 1) * 5
|
||||
// White background behind label
|
||||
const tw = ctx.measureText(label).width
|
||||
const lx = node.x - tw / 2 - 2
|
||||
const ly = node.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, node.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">Aucune note trouvée</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom to fit */}
|
||||
{!loading && graphData.nodes.length > 0 && (
|
||||
<button
|
||||
onClick={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"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
Vue globale
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cluster legend */}
|
||||
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1.5 max-h-[50vh] overflow-y-auto pr-1">
|
||||
{rawData.clusters.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-1.5 px-2 py-0.5 bg-white/90 border border-border/30 rounded-full shadow-sm">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(c.id) ?? '#94a3b8' }} />
|
||||
<span className="text-[9px] font-medium text-concrete/70 whitespace-nowrap">{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note detail panel */}
|
||||
{selectedNode && (
|
||||
<div className="absolute inset-y-0 right-0 w-80 bg-white border-l border-border/50 flex flex-col shadow-xl z-20">
|
||||
<div className="flex items-start justify-between p-4 border-b border-border/30">
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<h2 className="text-sm font-semibold text-ink leading-tight">{selectedNode.title}</h2>
|
||||
<p className="text-[10px] text-concrete/50 mt-1">
|
||||
{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
{' · '}{selectedNode.degree} connexion{selectedNode.degree !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedNode(null)} className="p-1 rounded text-concrete/40 hover:text-ink hover:bg-black/5">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{previewLoading ? (
|
||||
<Loader2 size={16} className="animate-spin text-concrete/30 mx-auto mt-8" />
|
||||
) : (
|
||||
<p className="text-xs text-concrete/70 leading-relaxed">
|
||||
{plainText(notePreview?.content) || <span className="italic text-concrete/30">Note vide</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 border-t border-border/30">
|
||||
<button
|
||||
onClick={() => router.push(`/notes/${selectedNode.id}`)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-md transition-colors"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
Ouvrir la note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
272
memento-note/components/personas-panel.tsx
Normal file
272
memento-note/components/personas-panel.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import {
|
||||
Wrench, TrendingUp, Users, ThumbsDown, Sun,
|
||||
Loader2, ChevronDown, ChevronUp, X, Copy, CheckCircle, Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
|
||||
// ── Personas definition ──────────────────────────────────────────────────────
|
||||
|
||||
const PERSONAS = [
|
||||
{
|
||||
id: 'engineer',
|
||||
label: 'Ingénieur',
|
||||
sublabel: 'Faisabilité · Risques tech',
|
||||
icon: Wrench,
|
||||
color: 'text-blue-600',
|
||||
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
||||
border: 'border-blue-200 dark:border-blue-800/50',
|
||||
hoverBorder: 'hover:border-blue-400/60',
|
||||
activeBg: 'bg-blue-600',
|
||||
},
|
||||
{
|
||||
id: 'financial',
|
||||
label: 'Financier',
|
||||
sublabel: 'ROI · Coûts · Viabilité',
|
||||
icon: TrendingUp,
|
||||
color: 'text-emerald-600',
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-950/30',
|
||||
border: 'border-emerald-200 dark:border-emerald-800/50',
|
||||
hoverBorder: 'hover:border-emerald-400/60',
|
||||
activeBg: 'bg-emerald-600',
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
label: 'Client',
|
||||
sublabel: 'UX · Valeur · Besoins',
|
||||
icon: Users,
|
||||
color: 'text-violet-600',
|
||||
bg: 'bg-violet-50 dark:bg-violet-950/30',
|
||||
border: 'border-violet-200 dark:border-violet-800/50',
|
||||
hoverBorder: 'hover:border-violet-400/60',
|
||||
activeBg: 'bg-violet-600',
|
||||
},
|
||||
{
|
||||
id: 'skeptic',
|
||||
label: 'Sceptique',
|
||||
sublabel: 'Points faibles · Angles morts',
|
||||
icon: ThumbsDown,
|
||||
color: 'text-rose-600',
|
||||
bg: 'bg-rose-50 dark:bg-rose-950/30',
|
||||
border: 'border-rose-200 dark:border-rose-800/50',
|
||||
hoverBorder: 'hover:border-rose-400/60',
|
||||
activeBg: 'bg-rose-600',
|
||||
},
|
||||
{
|
||||
id: 'optimist',
|
||||
label: 'Optimiste',
|
||||
sublabel: 'Opportunités · Potentiel',
|
||||
icon: Sun,
|
||||
color: 'text-amber-600',
|
||||
bg: 'bg-amber-50 dark:bg-amber-950/30',
|
||||
border: 'border-amber-200 dark:border-amber-800/50',
|
||||
hoverBorder: 'hover:border-amber-400/60',
|
||||
activeBg: 'bg-amber-600',
|
||||
},
|
||||
] as const
|
||||
|
||||
type PersonaId = (typeof PERSONAS)[number]['id']
|
||||
|
||||
interface PersonaResult {
|
||||
personaId: PersonaId
|
||||
personaLabel: string
|
||||
analysis: string
|
||||
}
|
||||
|
||||
interface PersonasPanelProps {
|
||||
noteTitle?: string
|
||||
noteContent?: string
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
|
||||
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
|
||||
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
|
||||
const [expanded, setExpanded] = useState<PersonaId | null>(null)
|
||||
const [copied, setCopied] = useState<PersonaId | null>(null)
|
||||
|
||||
const handlePersona = async (personaId: PersonaId) => {
|
||||
if (loadingId) return
|
||||
|
||||
// Toggle off if already showing
|
||||
if (expanded === personaId && results.has(personaId)) {
|
||||
setExpanded(null)
|
||||
return
|
||||
}
|
||||
|
||||
// If we already have the result, just expand it
|
||||
if (results.has(personaId)) {
|
||||
setExpanded(personaId)
|
||||
return
|
||||
}
|
||||
|
||||
if (!noteContent || noteContent.replace(/<[^>]+>/g, '').trim().split(/\s+/).length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingId(personaId)
|
||||
try {
|
||||
const res = await fetch('/api/ai/personas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Erreur')
|
||||
setResults(prev => new Map(prev).set(personaId, data))
|
||||
setExpanded(personaId)
|
||||
} catch {
|
||||
// silent error — user can retry
|
||||
} finally {
|
||||
setLoadingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (personaId: PersonaId) => {
|
||||
const result = results.get(personaId)
|
||||
if (!result) return
|
||||
navigator.clipboard.writeText(result.analysis).catch(() => {})
|
||||
setCopied(personaId)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
const handleClear = (personaId: PersonaId) => {
|
||||
setResults(prev => {
|
||||
const next = new Map(prev)
|
||||
next.delete(personaId)
|
||||
return next
|
||||
})
|
||||
if (expanded === personaId) setExpanded(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-brand-accent" />
|
||||
Prismes IA
|
||||
</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-concrete/60 leading-relaxed text-center px-2">
|
||||
Réinterprétez vos notes à travers différents prismes d'analyse.
|
||||
</p>
|
||||
|
||||
{/* Persona buttons */}
|
||||
<div className="space-y-2">
|
||||
{PERSONAS.map((persona) => {
|
||||
const Icon = persona.icon
|
||||
const isLoading = loadingId === persona.id
|
||||
const hasResult = results.has(persona.id)
|
||||
const isExpanded = expanded === persona.id
|
||||
const result = results.get(persona.id)
|
||||
|
||||
return (
|
||||
<div key={persona.id} className="rounded-xl overflow-hidden border border-border/60">
|
||||
{/* Persona row */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePersona(persona.id)}
|
||||
disabled={!!loadingId && !isLoading}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 p-3.5 transition-all text-left group',
|
||||
hasResult ? cn(persona.bg, persona.border, 'border-b') : 'bg-white/50 dark:bg-white/5 hover:bg-white dark:hover:bg-white/10',
|
||||
isLoading && 'animate-pulse',
|
||||
!!loadingId && !isLoading && 'opacity-40 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg shrink-0 transition-colors',
|
||||
hasResult ? cn(persona.bg, 'border', persona.border) : 'bg-slate-50 dark:bg-white/10',
|
||||
!hasResult && `group-hover:${persona.activeBg} group-hover:text-white`,
|
||||
persona.color,
|
||||
)}>
|
||||
{isLoading
|
||||
? <Loader2 size={14} className="animate-spin" />
|
||||
: <Icon size={14} />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-[11px] font-bold text-ink', hasResult && persona.color)}>
|
||||
{persona.label}
|
||||
</span>
|
||||
{hasResult && (
|
||||
<span className={cn('text-[8px] font-bold uppercase tracking-widest px-1.5 py-0.5 rounded-full', persona.bg, persona.color)}>
|
||||
prêt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[9px] text-concrete/60 uppercase tracking-tight">{persona.sublabel}</p>
|
||||
</div>
|
||||
{hasResult
|
||||
? isExpanded ? <ChevronUp size={14} className="text-concrete shrink-0" /> : <ChevronDown size={14} className="text-concrete shrink-0" />
|
||||
: null
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Expanded result */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && result && (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className={cn('p-4 space-y-3', persona.bg)}>
|
||||
<div className="text-[11px] leading-relaxed text-ink/80">
|
||||
<MarkdownContent content={result.analysis} />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(persona.id)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/70 dark:bg-black/20 border border-border/40 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all"
|
||||
>
|
||||
{copied === persona.id
|
||||
? <><CheckCircle size={10} className={persona.color} /> Copié</>
|
||||
: <><Copy size={10} /> Copier</>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClear(persona.id)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/70 dark:bg-black/20 border border-border/40 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-rose-500 transition-all"
|
||||
>
|
||||
<X size={10} /> Effacer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-border/40 bg-white/70 dark:bg-black/20 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all"
|
||||
title="Regénérer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleClear(persona.id)
|
||||
setTimeout(() => handlePersona(persona.id), 50)
|
||||
}}
|
||||
>
|
||||
↺ Regénérer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,12 +28,12 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||
|
||||
return (
|
||||
<nav className={`flex flex-wrap items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
|
||||
<nav className={`flex overflow-x-auto items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className="flex items-center gap-2.5 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
|
||||
className="flex items-center gap-1.5 sm:gap-2.5 px-2 sm:px-4 py-3 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
|
||||
style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
|
||||
>
|
||||
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
PinOff,
|
||||
Sparkles,
|
||||
Home,
|
||||
Network,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -182,6 +183,7 @@ function SidebarCarnetItem({
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
hasChildNotebooks,
|
||||
hasActiveDescendant,
|
||||
}: {
|
||||
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
||||
isActive: boolean
|
||||
@@ -201,6 +203,7 @@ function SidebarCarnetItem({
|
||||
isExpanded: boolean
|
||||
toggleExpand: () => void
|
||||
hasChildNotebooks?: boolean
|
||||
hasActiveDescendant?: boolean
|
||||
}) {
|
||||
const { t, language } = useLanguage()
|
||||
const isRtl = language === 'fa' || language === 'ar'
|
||||
@@ -287,6 +290,9 @@ function SidebarCarnetItem({
|
||||
{carnet.name}
|
||||
</span>
|
||||
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
||||
{!isActive && hasActiveDescendant && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
|
||||
@@ -424,6 +430,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
|
||||
// Écoute l'événement d'ouverture du menu mobile
|
||||
useEffect(() => {
|
||||
const handler = () => setIsMobileOpen(true)
|
||||
window.addEventListener('open-mobile-sidebar', handler)
|
||||
return () => window.removeEventListener('open-mobile-sidebar', handler)
|
||||
}, [])
|
||||
|
||||
// Ferme la sidebar mobile lors d'une navigation
|
||||
useEffect(() => {
|
||||
setIsMobileOpen(false)
|
||||
}, [pathname, searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
setIsDark(document.documentElement.classList.contains('dark'))
|
||||
@@ -631,22 +650,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
// Auto-expand all parent notebooks on first load
|
||||
useEffect(() => {
|
||||
if (orderedNotebooks.length === 0) return
|
||||
const parentIds = new Set<string>()
|
||||
for (const nb of orderedNotebooks) {
|
||||
if (nb.parentId) parentIds.add(nb.parentId)
|
||||
}
|
||||
if (parentIds.size > 0) {
|
||||
setExpandedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
for (const id of parentIds) next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [orderedNotebooks])
|
||||
|
||||
const togglePin = useCallback((id: string) => {
|
||||
setPinnedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -730,8 +733,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
return desc.some(c => currentNotebookId === c.id || hasDescendant(c.id))
|
||||
}
|
||||
const hasActiveDescendant = hasDescendant(notebook.id)
|
||||
// A notebook stays expanded if: manually expanded, has active descendant, OR is pinned
|
||||
const isExpanded = expandedIds.has(notebook.id) || hasActiveDescendant || pinnedIds.has(notebook.id)
|
||||
// A notebook stays expanded if: manually expanded OR is pinned
|
||||
const isExpanded = expandedIds.has(notebook.id) || pinnedIds.has(notebook.id)
|
||||
|
||||
return (
|
||||
<motion.div key={notebook.id}>
|
||||
@@ -784,7 +787,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
toggleExpand(notebook.id)
|
||||
} else {
|
||||
handleCarnetClick(notebook.id)
|
||||
if (!expandedIds.has(notebook.id)) toggleExpand(notebook.id)
|
||||
}
|
||||
}}
|
||||
onNoteClick={handleNoteClick}
|
||||
@@ -802,6 +804,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
isExpanded={isExpanded}
|
||||
toggleExpand={() => toggleExpand(notebook.id)}
|
||||
hasChildNotebooks={children.length > 0}
|
||||
hasActiveDescendant={hasActiveDescendant}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && renderCarnetTree(notebook.id, level + 1)}
|
||||
@@ -812,10 +815,29 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay mobile */}
|
||||
<AnimatePresence>
|
||||
{isMobileOpen && (
|
||||
<motion.div
|
||||
key="sidebar-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60] md:hidden"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
|
||||
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
|
||||
// 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',
|
||||
'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
|
||||
)}
|
||||
>
|
||||
@@ -1145,7 +1167,25 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="my-2 h-px bg-border/20 mx-2" />
|
||||
{/* ── 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>
|
||||
</aside>
|
||||
|
||||
111
memento-note/components/wikilinks-backlinks-panel.tsx
Normal file
111
memento-note/components/wikilinks-backlinks-panel.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link2, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface BacklinkNote {
|
||||
id: string
|
||||
title: string | null
|
||||
updatedAt: string
|
||||
notebookId: string | null
|
||||
}
|
||||
|
||||
interface Backlink {
|
||||
id: string
|
||||
sourceNote: BacklinkNote
|
||||
contextSnippet: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface WikilinksBacklinksPanelProps {
|
||||
noteId: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function WikilinksBacklinksPanel({ noteId, className }: WikilinksBacklinksPanelProps) {
|
||||
const [backlinks, setBacklinks] = useState<Backlink[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(true)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteId) return
|
||||
setLoading(true)
|
||||
fetch(`/api/notes/${noteId}/backlinks`)
|
||||
.then(r => r.json())
|
||||
.then(data => setBacklinks(data.backlinks || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [noteId])
|
||||
|
||||
if (loading && backlinks.length === 0) return null
|
||||
if (!loading && backlinks.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="flex items-center gap-2 group w-full"
|
||||
>
|
||||
<Link2 size={14} className="text-concrete shrink-0" />
|
||||
<span className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete group-hover:text-ink transition-colors">
|
||||
Liens entrants
|
||||
</span>
|
||||
<span className="text-[9px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.5 rounded-full font-bold">
|
||||
{backlinks.length}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn('text-concrete transition-transform', open && 'rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden pl-5 space-y-1.5"
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2 size={12} className="animate-spin text-concrete" />
|
||||
<span className="text-[10px] text-concrete">Chargement…</span>
|
||||
</div>
|
||||
)}
|
||||
{backlinks.map(bl => (
|
||||
<button
|
||||
key={bl.id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/notes/${bl.sourceNote.id}`)}
|
||||
className="w-full text-left group/bl p-2.5 rounded-lg bg-white/50 dark:bg-white/5 border border-border/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Link2 size={10} className="text-brand-accent/60 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold text-ink truncate group-hover/bl:text-brand-accent transition-colors">
|
||||
{bl.sourceNote.title || '(Sans titre)'}
|
||||
</p>
|
||||
{bl.contextSnippet && (
|
||||
<p className="text-[9px] text-concrete/70 mt-0.5 line-clamp-2 leading-relaxed">
|
||||
…{bl.contextSnippet}…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user