feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s

- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export
- Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG)
- Add document Q&A overlay with streaming chat and PDF preview
- Add note attachments UI with status polling, grid layout, and auto-scroll
- Add task extraction AI tool and agent executor improvements
- Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings
- Fix brainstorm 'Create Note' button: add success toast and redirect to created note
- Fix memory echo notification infinite polling
- Fix chat route to always include document_search tool
- Add brainstorm i18n keys across all 14 locales
- Add socket server for real-time brainstorm collaboration
- Add hierarchical notebook selector and organize notebook dialog improvements
- Add sidebar brainstorm section with session management
- Update prisma schema with brainstorm tables, attachments, and document chunks
This commit is contained in:
Antigravity
2026-05-14 17:43:21 +00:00
parent 195e845f0a
commit 1fcea6ed7d
228 changed files with 57656 additions and 1059 deletions

View File

@@ -18,6 +18,7 @@ import {
Pencil,
Activity,
Presentation,
ListChecks,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
@@ -51,6 +52,7 @@ const typeConfig: Record<string, { icon: typeof Globe }> = {
custom: { icon: Settings },
'slide-generator': { icon: Presentation },
'excalidraw-generator': { icon: Pencil },
'task-extractor': { icon: ListChecks },
}
const frequencyKeys: Record<string, string> = {

View File

@@ -29,13 +29,14 @@ import {
Loader2,
BookOpen,
LifeBuoy,
ListChecks,
} from 'lucide-react'
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator' | 'task-extractor'
function FieldHelp({ tooltip }: { tooltip: string }) {
return (
@@ -59,6 +60,7 @@ const typeIcons: Record<string, typeof Globe> = {
custom: Settings,
'slide-generator': Presentation,
'excalidraw-generator': Pencil,
'task-extractor': ListChecks,
}
const TOOL_PRESETS: Record<string, string[]> = {
@@ -68,6 +70,7 @@ const TOOL_PRESETS: Record<string, string[]> = {
custom: ['memory_search'],
'slide-generator': ['generate_pptx'],
'excalidraw-generator': ['generate_excalidraw'],
'task-extractor': ['note_search', 'note_read', 'task_extract', 'note_create'],
}
interface AgentDetailViewProps {
@@ -386,6 +389,7 @@ export function AgentDetailView({
{ value: 'custom' as AgentType, labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom', icon: Settings },
{ value: 'slide-generator' as AgentType, labelKey: 'agents.types.slideGenerator', descKey: 'agents.typeDescriptions.slideGenerator', icon: Presentation },
{ value: 'excalidraw-generator' as AgentType, labelKey: 'agents.types.excalidrawGenerator', descKey: 'agents.typeDescriptions.excalidrawGenerator', icon: Pencil },
{ value: 'task-extractor' as AgentType, labelKey: 'agents.types.taskExtractor', descKey: 'agents.typeDescriptions.taskExtractor', icon: ListChecks },
].map(at => {
const TypeIcon = at.icon
return (

View File

@@ -10,6 +10,7 @@ import {
Loader2,
Presentation,
Pencil,
ListChecks,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
@@ -42,6 +43,7 @@ const templateConfig = [
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', urls: [], frequency: 'manual' },
{ id: 'slideGenerator', type: 'slide-generator', roleKey: 'agents.defaultRoles.slideGenerator', urls: [], frequency: 'manual' },
{ id: 'excalidrawGenerator', type: 'excalidraw-generator', roleKey: 'agents.defaultRoles.excalidrawGenerator', urls: [], frequency: 'manual' },
{ id: 'taskExtractor', type: 'task-extractor', roleKey: 'agents.defaultRoles.taskExtractor', urls: [], frequency: 'manual' },
] as const
const typeIcons: Record<string, typeof Globe> = {
@@ -51,6 +53,7 @@ const typeIcons: Record<string, typeof Globe> = {
custom: Settings,
'slide-generator': Presentation,
'excalidraw-generator': Pencil,
'task-extractor': ListChecks,
}
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
@@ -87,7 +90,9 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
? ['note_search', 'note_read', 'generate_pptx']
: tpl.type === 'excalidraw-generator'
? ['note_search', 'note_read', 'generate_excalidraw']
: [],
: tpl.type === 'task-extractor'
? ['note_search', 'note_read', 'task_extract', 'note_create']
: [],
})
toast.success(t('agents.toasts.installSuccess', { name: resolvedName }))
onInstalled()

View File

@@ -152,7 +152,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
return (
<Button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105 bg-muted text-foreground hover:bg-muted/80 border border-border"
className="fixed bottom-6 end-6 h-12 w-12 rounded-full shadow-xl z-40 transition-transform hover:scale-105 bg-muted text-foreground hover:bg-muted/80 border border-border"
size="icon"
title={t('ai.openAssistant')}
>
@@ -163,7 +163,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
return (
<aside className={cn(
"fixed bottom-20 right-6 border border-border/40 bg-memento-paper dark:bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
"fixed bottom-20 end-6 border border-border/40 bg-memento-paper dark:bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
)}>
{/* Header */}
@@ -239,7 +239,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{/* AI Welcome Message */}
{messages.length === 0 && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
<div className="w-8 h-8 rounded-full bg-memento-blue/20 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/30 shadow-sm">
<Bot className="h-4 w-4" />
</div>
<div className="bg-memento-paper dark:bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
@@ -261,7 +261,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 border text-[10px] font-bold',
msg.role === 'user'
? 'bg-muted border-border text-muted-foreground'
: 'bg-memento-blue/10 text-memento-blue border-memento-blue/20',
: 'bg-memento-blue/20 text-memento-blue border-memento-blue/30 shadow-sm',
)}>
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
</div>
@@ -323,7 +323,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
history.map(conv => (
<button
key={conv.id}
className="w-full text-left p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-memento-blue/30 transition-all flex flex-col gap-1"
className="w-full text-start p-3 rounded-xl border border-border/50 hover:bg-muted/50 hover:border-memento-blue/30 transition-all flex flex-col gap-1"
onClick={() => {
setConversationId(conv.id)
setMessages(conv.messages.map((m: any) => ({
@@ -353,22 +353,22 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
<div className={cn("p-4 border-t border-border/40 bg-memento-paper dark:bg-background shrink-0", activeTab !== 'chat' && "hidden")}>
{/* Context Scope */}
<div className="mb-3 space-y-2">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block ml-1">Source du Contexte</span>
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block ms-1">Source du Contexte</span>
<button
onClick={() => setChatScope('all')}
className={cn(
'w-full p-2.5 border rounded-lg text-xs flex items-center justify-between transition-all',
chatScope === 'all' ? 'bg-blueprint/10 border-blueprint/30' : 'bg-card border-border hover:border-foreground/20'
chatScope === 'all' ? 'bg-memento-blue/15 border-memento-blue/40 shadow-inner' : 'bg-card border-border hover:border-foreground/20'
)}
>
<div className="flex items-center gap-2">
<Layers className="h-3.5 w-3.5 text-blueprint/60" />
<span className={cn('font-medium', chatScope === 'all' ? 'text-blueprint' : 'text-foreground/60')}>
<Layers className="h-3.5 w-3.5 text-memento-blue/70" />
<span className={cn('font-bold', chatScope === 'all' ? 'text-memento-blue' : 'text-foreground/60')}>
{t('ai.allMyNotes') || 'Toutes mes notes'}
</span>
</div>
{chatScope === 'all' && (
<span className="text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold">Auto</span>
<span className="text-[8px] bg-memento-blue/20 text-memento-blue px-1.5 py-0.5 rounded uppercase font-bold border border-memento-blue/30">Auto</span>
)}
</button>
@@ -389,7 +389,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
{/* Tone Selection */}
<div className="mb-3">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ml-1">{t('ai.writingTone')}</span>
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5 ms-1">{t('ai.writingTone')}</span>
<div className="grid grid-cols-4 gap-1">
{TONES.map(tone => {
const Icon = tone.icon
@@ -402,7 +402,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
className={cn(
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
isSelected
? "border-memento-blue bg-memento-blue/10 text-memento-blue shadow-sm"
? "border-memento-blue bg-memento-blue/15 text-memento-blue shadow-sm font-bold"
: "border-border/60 bg-memento-paper dark:bg-background text-muted-foreground hover:bg-muted hover:border-border"
)}
>
@@ -454,7 +454,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
onClick={handleSend}
disabled={!input.trim()}
>
<Send className="h-4 w-4 ml-0.5" />
<Send className="h-4 w-4 ms-0.5" />
</Button>
)}
</div>

View File

@@ -0,0 +1,120 @@
'use client'
import React from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Activity, Lightbulb, UserPlus, X, Zap, Eye } from 'lucide-react'
import type { BrainstormActivityItem } from '@/types/brainstorm'
interface ActivityFeedProps {
activities: BrainstormActivityItem[]
isOpen: boolean
onToggle: () => void
t: (key: string) => string | undefined
}
function getActionIcon(action: string) {
switch (action) {
case 'manual_idea': return <Lightbulb size={12} className="text-memento-blue" />
case 'wave_generated': return <Zap size={12} className="text-orange-500" />
case 'joined': return <UserPlus size={12} className="text-emerald-500" />
case 'idea_dismissed': return <X size={12} className="text-rose-500" />
case 'invite_created': return <UserPlus size={12} className="text-violet-500" />
default: return <Activity size={12} className="text-muted-foreground" />
}
}
function getActionLabel(action: string, t: (key: string) => string | undefined): string {
const key = `brainstorm.activity.${action}`
const translated = t(key)
if (translated) return translated
switch (action) {
case 'manual_idea': return 'added an idea'
case 'wave_generated': return 'generated a wave'
case 'joined': return 'joined the session'
case 'idea_dismissed': return 'dismissed an idea'
case 'invite_created': return 'created an invite'
default: return action
}
}
function timeAgo(dateStr: string, t: (key: string) => string | undefined): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return t('brainstorm.justNow') || 'just now'
if (mins < 60) return `${mins}m`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
}
export function ActivityFeed({ activities, isOpen, onToggle, t }: ActivityFeedProps) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: 320, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 320, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="absolute top-0 right-16 bottom-0 w-[320px] bg-white/95 dark:bg-[#1A1A1A]/95 backdrop-blur-xl border-l border-border z-30 flex flex-col shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
>
<div className="p-6 border-b border-border/40 flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity size={14} className="text-orange-500" />
<h3 className="text-[10px] font-bold uppercase tracking-[0.2em] text-foreground">
{t('brainstorm.activityTitle') || 'Activity'}
</h3>
</div>
<button
onClick={onToggle}
className="p-1.5 hover:bg-foreground/5 rounded-full transition-colors"
>
<X size={14} className="text-muted-foreground" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{activities.length === 0 ? (
<div className="p-6 text-center">
<p className="text-xs italic text-muted-foreground">
{t('brainstorm.noActivity') || 'No activity yet'}
</p>
</div>
) : (
<div className="py-2">
{activities.map((item, idx) => (
<motion.div
key={item.id}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.03 }}
className="px-6 py-3 flex items-start gap-3 hover:bg-foreground/[0.02] transition-colors"
>
<div className="mt-0.5 w-5 h-5 rounded-full bg-foreground/5 flex items-center justify-center shrink-0">
{getActionIcon(item.action)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground leading-relaxed">
<span className="font-semibold">
{item.user?.name || 'AI'}
</span>{' '}
{getActionLabel(item.action, t)}
{item.details?.ideaTitle && (
<span className="text-muted-foreground italic"> « {item.details.ideaTitle.length > 30 ? item.details.ideaTitle.substring(0, 30) + '…' : item.details.ideaTitle} »</span>
)}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{timeAgo(item.createdAt, t)}
</p>
</div>
</motion.div>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,370 @@
'use client'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import { BrainstormSession, BrainstormIdea } from '@/types/brainstorm'
import { useExpandIdea, useDismissIdea, useConvertIdea, useExportBrainstorm } from '@/hooks/use-brainstorm'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Sparkles, X, FileText, Download, ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
ssr: false,
})
interface GraphNode {
id: string
name: string
val: number
color: string
borderColor: string
wave: number
status: string
idea: BrainstormIdea
x?: number
y?: number
__bckgDimensions?: [number, number]
}
interface GraphLink {
source: string | GraphNode
target: string | GraphNode
color: string
}
const WAVE_COLORS: Record<number, string> = {
0: '#ffffff',
1: '#f97316',
2: '#3b82f6',
3: '#a855f7',
}
const WAVE_BORDER: Record<number, string> = {
0: '#e5e5e5',
1: '#ea580c',
2: '#2563eb',
3: '#9333ea',
}
const STATUS_ALPHA: Record<string, number> = {
active: 1,
dismissed: 0.25,
converted: 0.9,
}
interface BrainstormCanvasProps {
session: BrainstormSession
}
export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
const fgRef = useRef<any>(null)
const [selectedIdea, setSelectedIdea] = useState<BrainstormIdea | null>(null)
const [isSheetOpen, setIsSheetOpen] = useState(false)
const expandIdea = useExpandIdea(session.id)
const dismissIdea = useDismissIdea(session.id)
const convertIdea = useConvertIdea(session.id)
const exportBrainstorm = useExportBrainstorm(session.id)
const { graphData } = useMemo(() => {
const nodes: GraphNode[] = []
const links: GraphLink[] = []
nodes.push({
id: 'seed',
name: session.seedIdea.length > 30 ? session.seedIdea.slice(0, 30) + '...' : session.seedIdea,
val: 25,
color: WAVE_COLORS[0],
borderColor: WAVE_BORDER[0],
wave: 0,
status: 'active',
idea: {
id: 'seed',
sessionId: session.id,
waveNumber: 0,
title: session.seedIdea,
description: 'Original seed idea',
connectionToSeed: null,
noveltyScore: null,
parentIdeaId: null,
convertedToNoteId: null,
relatedNoteIds: null,
status: 'active',
positionX: null,
positionY: null,
createdAt: session.createdAt,
} as BrainstormIdea,
})
for (const idea of session.ideas) {
const parentNode = idea.parentIdeaId
? idea.parentIdeaId
: 'seed'
nodes.push({
id: idea.id,
name: idea.title,
val: idea.status === 'dismissed' ? 8 : 15,
color: WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3],
borderColor: idea.status === 'converted' ? '#22c55e' : (WAVE_BORDER[idea.waveNumber] || WAVE_BORDER[3]),
wave: idea.waveNumber,
status: idea.status,
idea,
})
const linkColor = WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3]
links.push({
source: parentNode,
target: idea.id,
color: idea.status === 'dismissed' ? '#444444' : linkColor,
})
}
return { graphData: { nodes, links } }
}, [session])
const handleNodeClick = useCallback((node: any) => {
setSelectedIdea(node.idea)
setIsSheetOpen(true)
}, [])
const handleExpand = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await expandIdea.mutateAsync(selectedIdea.id)
toast.success('Ideas expanded!')
} catch (err: any) {
toast.error(err.message || 'Failed to expand')
}
}, [selectedIdea, expandIdea])
const handleDismiss = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await dismissIdea.mutateAsync(selectedIdea.id)
setIsSheetOpen(false)
setSelectedIdea(null)
toast.success('Idea dismissed')
} catch (err: any) {
toast.error(err.message || 'Failed to dismiss')
}
}, [selectedIdea, dismissIdea])
const handleConvert = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await convertIdea.mutateAsync(selectedIdea.id)
toast.success('Idea converted to note!')
} catch (err: any) {
toast.error(err.message || 'Failed to convert')
}
}, [selectedIdea, convertIdea])
const handleExport = useCallback(async () => {
try {
const note = await exportBrainstorm.mutateAsync()
toast.success('Exported as note!')
} catch (err: any) {
toast.error(err.message || 'Failed to export')
}
}, [exportBrainstorm])
const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const label = node.name
const fontSize = Math.max(12 / globalScale, 4)
ctx.font = `${fontSize}px Sans-Serif`
const textWidth = ctx.measureText(label).width
const nodeSize = node.val
const bgDimensions: [number, number] = [textWidth + nodeSize * 0.8, nodeSize * 1.2]
node.__bckgDimensions = bgDimensions
const alpha = STATUS_ALPHA[node.status] || 1
ctx.globalAlpha = alpha
ctx.fillStyle = node.color
ctx.strokeStyle = node.borderColor
ctx.lineWidth = node.status === 'converted' ? 3 / globalScale : 1.5 / globalScale
ctx.beginPath()
ctx.roundRect(
node.x! - bgDimensions[0] / 2,
node.y! - bgDimensions[1] / 2,
bgDimensions[0],
bgDimensions[1],
nodeSize * 0.3
)
ctx.fill()
ctx.stroke()
ctx.fillStyle = node.wave === 0 ? '#000000' : '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(label, node.x!, node.y!)
if (node.status === 'converted') {
ctx.fillStyle = '#22c55e'
ctx.font = `${fontSize * 1.2}px Sans-Serif`
ctx.fillText('✓', node.x! + bgDimensions[0] / 2 - fontSize * 0.5, node.y! - bgDimensions[1] / 2 + fontSize * 0.5)
}
ctx.globalAlpha = 1
}, [])
const waveLegend = [
{ label: 'Seed', color: WAVE_COLORS[0] },
{ label: 'Variations', color: WAVE_COLORS[1] },
{ label: 'Analogies', color: WAVE_COLORS[2] },
{ label: 'Disruptions', color: WAVE_COLORS[3] },
]
return (
<div className="relative w-full h-full bg-zinc-950 rounded-xl overflow-hidden">
<ForceGraph2D
ref={fgRef}
graphData={graphData}
nodeCanvasObject={paintNode}
nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => {
const dims = node.__bckgDimensions
if (!dims) return
ctx.fillStyle = color
ctx.beginPath()
ctx.roundRect(node.x! - dims[0] / 2, node.y! - dims[1] / 2, dims[0], dims[1], (node.val || 10) * 0.3)
ctx.fill()
}}
onNodeClick={handleNodeClick}
linkColor={(link: any) => link.color}
linkWidth={1}
linkDirectionalArrowLength={3}
linkDirectionalArrowRelPos={1}
backgroundColor="#09090b"
nodeVal={(node: any) => node.val}
cooldownTicks={100}
enableNodeDrag={true}
enableZoomInteraction={true}
enablePanInteraction={true}
warmupTicks={50}
/>
<div className="absolute top-4 left-4 flex flex-col gap-1.5">
{waveLegend.map(w => (
<div key={w.label} className="flex items-center gap-2 text-xs text-zinc-400">
<div className="w-3 h-3 rounded-sm border border-zinc-600" style={{ backgroundColor: w.color }} />
<span>{w.label}</span>
</div>
))}
</div>
<div className="absolute top-4 right-4">
<Button
size="sm"
variant="outline"
className="bg-zinc-800 border-zinc-700 text-zinc-300 hover:bg-zinc-700 hover:text-white"
onClick={handleExport}
disabled={exportBrainstorm.isPending}
>
<Download size={14} className="mr-1" />
{exportBrainstorm.isPending ? 'Exporting...' : 'Export'}
</Button>
</div>
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetContent className="bg-zinc-900 border-zinc-800 text-white w-96">
{selectedIdea && (
<>
<SheetHeader>
<SheetTitle className="text-white text-left">
{selectedIdea.id === 'seed' ? session.seedIdea : selectedIdea.title}
</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-4">
{selectedIdea.description && selectedIdea.id !== 'seed' && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Description</p>
<p className="text-sm text-zinc-300">{selectedIdea.description}</p>
</div>
)}
{selectedIdea.connectionToSeed && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Connection</p>
<p className="text-sm text-zinc-300">{selectedIdea.connectionToSeed}</p>
</div>
)}
{selectedIdea.noveltyScore && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Novelty</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-orange-500 via-memento-blue to-purple-500 rounded-full"
style={{ width: `${selectedIdea.noveltyScore * 10}%` }}
/>
</div>
<span className="text-sm text-zinc-400">{selectedIdea.noveltyScore}/10</span>
</div>
</div>
)}
{selectedIdea.waveNumber > 0 && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Wave</p>
<span
className="inline-block px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: WAVE_COLORS[selectedIdea.waveNumber] + '30',
color: WAVE_COLORS[selectedIdea.waveNumber],
}}
>
{selectedIdea.waveNumber === 1 ? 'Variation' : selectedIdea.waveNumber === 2 ? 'Analogy' : 'Disruption'}
</span>
</div>
)}
{selectedIdea.status === 'converted' && (
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p className="text-xs text-green-400 flex items-center gap-1">
<FileText size={12} />
Converted to note
</p>
</div>
)}
{selectedIdea.id !== 'seed' && selectedIdea.status === 'active' && (
<div className="flex flex-col gap-2 pt-4 border-t border-zinc-800">
<Button
onClick={handleExpand}
disabled={expandIdea.isPending}
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
>
<Sparkles size={14} className="mr-1" />
{expandIdea.isPending ? 'Generating...' : 'Dig Deeper'}
</Button>
<Button
onClick={handleConvert}
disabled={convertIdea.isPending}
className="w-full bg-memento-blue hover:bg-blue-700 text-white"
>
<FileText size={14} className="mr-1" />
{convertIdea.isPending ? 'Converting...' : 'Create Note'}
</Button>
<Button
onClick={handleDismiss}
disabled={dismissIdea.isPending}
variant="outline"
className="w-full border-zinc-700 text-zinc-400 hover:text-white hover:bg-zinc-800"
>
<X size={14} className="mr-1" />
Dismiss
</Button>
</div>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import React, { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface BrainstormCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (seedIdea: string) => void
isLoading?: boolean
}
export function BrainstormCreateDialog({
open,
onOpenChange,
onSubmit,
isLoading,
}: BrainstormCreateDialogProps) {
const { t } = useLanguage()
const [seedIdea, setSeedIdea] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!seedIdea.trim()) return
onSubmit(seedIdea.trim())
setSeedIdea('')
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-zinc-900 border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles size={18} className="text-orange-500" />
{t('brainstorm.newBrainstorm')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider mb-1 block">
{t('brainstorm.seedLabel')}
</label>
<textarea
value={seedIdea}
onChange={(e) => setSeedIdea(e.target.value)}
placeholder={t('brainstorm.ideaPromptDetailed')}
className="w-full h-28 px-3 py-2 text-sm border border-border rounded-lg bg-transparent focus:outline-none focus:ring-2 focus:ring-orange-500/30 resize-none"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('brainstorm.cancel')}
</Button>
<Button
type="submit"
disabled={!seedIdea.trim() || isLoading}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
{isLoading ? (
<>
<Sparkles size={14} className="mr-1 animate-spin" />
{t('brainstorm.generating')}
</>
) : (
<>
<Sparkles size={14} className="mr-1" />
{t('brainstorm.startBrainstorm')}
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,817 @@
'use client'
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { motion, AnimatePresence } from 'motion/react'
import {
Zap,
History,
Plus,
Wind,
FileText,
ChevronRight,
UserPlus,
Activity,
Download,
Share2,
Check,
Users,
Globe,
Lock,
} from 'lucide-react'
import dynamic from 'next/dynamic'
import { useLanguage } from '@/lib/i18n'
import {
useBrainstormSession,
useBrainstormSessions,
useCreateBrainstorm,
useExpandIdea,
useDismissIdea,
useConvertIdea,
useExportBrainstorm,
useFinalizeBrainstorm,
useDeleteBrainstorm,
useJoinBrainstorm,
useAddManualIdea,
useBrainstormActivity,
useUpdateBrainstormSettings,
} from '@/hooks/use-brainstorm'
import { useBrainstormSocket } from '@/hooks/use-brainstorm-socket'
import { LiveCursors, PresenceAvatars, useCursorTracking } from '@/components/brainstorm/live-cursors'
import { ActivityFeed } from '@/components/brainstorm/activity-feed'
import { BrainstormShareDialog } from '@/components/brainstorm/brainstorm-share-dialog'
import { GhostCursor } from '@/components/brainstorm/ghost-cursor'
import { PlaybackBar } from '@/components/brainstorm/playback-bar'
import type { BrainstormIdea, BrainstormNoteRef, BrainstormActivityItem } from '@/types/brainstorm'
const WaveCanvas = dynamic(
() =>
import('@/components/brainstorm/wave-canvas').then((m) => ({
default: m.WaveCanvas,
})),
{ ssr: false }
)
function CursorTrackerEffect({ containerRef, moveCursor }: { containerRef: React.RefObject<HTMLDivElement | null>; moveCursor: (c: { x: number; y: number } | null) => void }) {
useCursorTracking(containerRef, moveCursor)
return null
}
const WAVE_COLORS: Record<number, { border: string; bg: string; text: string }> = {
1: { border: 'border-orange-200', bg: 'bg-orange-50', text: 'text-orange-600' },
2: { border: 'border-memento-blue', bg: 'bg-memento-blue', text: 'text-memento-blue' },
3: { border: 'border-violet-200', bg: 'bg-violet-50', text: 'text-violet-600' },
}
export function BrainstormPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { t, language } = useLanguage()
const { data: authSession } = useSession()
const urlSessionId = searchParams.get('session')
const urlSeed = searchParams.get('seed')
const urlSourceNoteId = searchParams.get('sourceNoteId')
const urlInviteToken = searchParams.get('invite')
const [seedInput, setSeedInput] = useState('')
const [selectedIdeaId, setSelectedIdeaId] = useState<string | null>(null)
const [activeSessionId, setActiveSessionId] = useState<string | null>(urlSessionId)
const [autoStarted, setAutoStarted] = useState(false)
useEffect(() => {
if (urlSessionId && urlSessionId !== activeSessionId) {
setActiveSessionId(urlSessionId)
}
}, [urlSessionId])
const [showActivityFeed, setShowActivityFeed] = useState(false)
const [showShareDialog, setShowShareDialog] = useState(false)
const [manualEditCount, setManualEditCount] = useState(0)
const [shareStatus, setShareStatus] = useState<'idle' | 'copying' | 'copied'>('idle')
const { data: sessions, isLoading: sessionsLoading } = useBrainstormSessions()
const { data: sessionResult, isLoading: sessionLoading } = useBrainstormSession(activeSessionId)
const session = sessionResult?.session || null
const sessionMeta = sessionResult?.meta
const isGuest = sessionMeta?.role === 'guest'
const canEdit = sessionMeta?.canEdit ?? true
const updateSettings = useUpdateBrainstormSettings(activeSessionId || '')
const createBrainstorm = useCreateBrainstorm()
const expandIdea = useExpandIdea(activeSessionId || '')
const dismissIdea = useDismissIdea(activeSessionId || '')
const convertIdea = useConvertIdea(activeSessionId || '')
const exportBrainstorm = useExportBrainstorm(activeSessionId || '')
const finalizeBrainstorm = useFinalizeBrainstorm(activeSessionId || '')
const deleteBrainstorm = useDeleteBrainstorm()
const joinMutation = useJoinBrainstorm()
const addManualIdea = useAddManualIdea(activeSessionId || '')
const { data: activities } = useBrainstormActivity(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 canvasContainerRef = useRef<HTMLDivElement>(null)
const moveSeq = useRef(0)
const { others: socketOthers, moveCursor, activities: socketActivities, socketRef, aiProcessingNodeId } = useBrainstormSocket(
activeSessionId,
authSession?.user?.id || null,
authSession?.user?.name || null,
useCallback((data: { ideaId: string; positionX: number; positionY: number }) => {
moveSeq.current++
setRemoteMove({ ideaId: data.ideaId, x: data.positionX, y: data.positionY, _seq: moveSeq.current })
}, [])
)
const mergedActivities: BrainstormActivityItem[] = useMemo(() => {
const restActivities: BrainstormActivityItem[] = (activities || []) as BrainstormActivityItem[]
const socketActs = (socketActivities || []).map((a: any) => ({
id: `${a.userId}-${a.action}-${Date.now()}-${Math.random()}`,
action: a.action,
details: a.details,
createdAt: new Date().toISOString(),
user: { name: a.userName || null, image: null },
}))
const seen = new Set<string>()
const merged = [...socketActs, ...restActivities]
return merged.filter((a) => {
const key = `${a.action}-${a.user?.name}-${a.details?.ideaTitle || ''}`
if (seen.has(key)) return false
seen.add(key)
return true
}).slice(0, 50)
}, [activities, socketActivities])
const selectedIdea = useMemo(() => {
if (!selectedIdeaId || !session) return null
return session.ideas.find((i) => i.id === selectedIdeaId) || null
}, [selectedIdeaId, session])
useEffect(() => {
if (urlSeed && !autoStarted && !activeSessionId && !createBrainstorm.isPending) {
setAutoStarted(true)
createBrainstorm.mutateAsync({
seedIdea: urlSeed,
sourceNoteId: urlSourceNoteId || undefined,
locale: language,
}).then((result) => {
setActiveSessionId(result.session.id)
router.replace('/brainstorm?session=' + result.session.id)
}).catch(() => {})
}
}, [urlSeed, autoStarted, activeSessionId])
useEffect(() => {
if (urlInviteToken && !autoStarted) {
setAutoStarted(true)
joinMutation.mutateAsync(urlInviteToken).then((result) => {
setActiveSessionId(result.sessionId)
router.replace('/brainstorm?session=' + result.sessionId)
}).catch(() => {})
}
}, [urlInviteToken])
const handleStartBrainstorm = async (seed?: string) => {
const input = seed || seedInput
if (!input.trim()) return
try {
const result = await createBrainstorm.mutateAsync({ seedIdea: input.trim(), locale: language })
setActiveSessionId(result.session.id)
setSeedInput('')
} catch {}
}
const handleDelete = async (sessionId: string) => {
try {
await deleteBrainstorm.mutateAsync(sessionId)
if (activeSessionId === sessionId) {
setActiveSessionId(null)
setSelectedIdeaId(null)
}
} catch {}
}
const handleDeepen = async (idea: BrainstormIdea) => {
try {
await expandIdea.mutateAsync({ ideaId: idea.id, locale: language })
} catch {}
}
const handleDismiss = async (ideaId: string) => {
try {
await dismissIdea.mutateAsync(ideaId)
setSelectedIdeaId(null)
} catch {}
}
const handleConvert = async (idea: BrainstormIdea) => {
try {
const result = await convertIdea.mutateAsync(idea.id)
if (result?.id) {
setConvertToast({ noteTitle: result.title || idea.title, noteId: result.id })
setTimeout(() => {
setConvertToast(null)
router.push(`/?openNote=${result.id}`)
}, 2000)
}
} catch {}
}
const handleExport = async () => {
setExportError(null)
try {
const result = await exportBrainstorm.mutateAsync()
if (result?.id) {
const notebookName = result._notebookName || 'Brainstorm'
setExportToast({ noteTitle: result.title || 'Synthèse', notebookName })
setTimeout(() => {
setExportToast(null)
router.push(`/?openNote=${result.id}`)
}, 2000)
return
}
const impact = await finalizeBrainstorm.mutateAsync()
if (impact) {
setImpactToast(impact)
setTimeout(() => setImpactToast(null), 5000)
}
} catch (err: any) {
setExportError(err?.message || 'Export failed')
setTimeout(() => setExportError(null), 4000)
}
}
const handlePositionUpdate = async (id: string, pos: { x: number; y: number }) => {
if (!activeSessionId) return
socketRef.current?.emit('idea:moved', { ideaId: id, positionX: pos.x, positionY: pos.y, userId: authSession?.user?.id || '' })
try {
await fetch(`/api/brainstorm/${activeSessionId}/update-position`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ideaId: id, positionX: pos.x, positionY: pos.y }),
})
} catch {}
}
const handleCreateIdea = useCallback(({ title, parentIdeaId }: { title: string; parentIdeaId?: string; x: number; y: number }) => {
addManualIdea.mutate({ title, parentIdeaId, locale: language })
}, [addManualIdea, language])
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="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
style={{
backgroundImage:
'linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
<div className="max-w-4xl mx-auto relative">
<div className="flex items-center gap-5 mb-8">
<motion.div
animate={{ rotate: isGenerating ? 360 : 0 }}
transition={{
repeat: isGenerating ? Infinity : 0,
duration: 20,
ease: 'linear',
}}
className="w-14 h-14 rounded-2xl bg-orange-500 shadow-[0_0_20px_rgba(249,115,22,0.2)] flex items-center justify-center text-white"
>
<Wind size={28} />
</motion.div>
<div className="flex-1">
<h1 className="text-4xl font-serif font-medium text-foreground tracking-tight">
{t('brainstorm.title') || 'Waves of Thought'}
</h1>
<div className="flex items-center gap-2 mt-1">
<span className="w-8 h-px bg-orange-400/40" />
<p className="text-[10px] text-muted-foreground tracking-[0.3em] uppercase font-bold">
{t('brainstorm.subtitle') || 'Unfold dimensions of potentiality'}
</p>
</div>
</div>
{session && !isGuest && (
<div className="flex items-center gap-3">
<button
onClick={handleExport}
disabled={exportBrainstorm.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-orange-500 transition-all shadow-sm disabled:opacity-50"
title={t('brainstorm.export') || 'Export'}
>
<Download size={14} />
<span className="hidden sm:inline">{t('brainstorm.export') || 'Export'}</span>
</button>
<button
onClick={() => setShowActivityFeed(!showActivityFeed)}
className={`flex items-center gap-2 px-4 py-2 border border-border rounded-xl text-xs font-bold uppercase tracking-widest transition-all shadow-sm ${showActivityFeed ? 'bg-foreground text-background' : 'bg-white dark:bg-white/5 text-muted-foreground hover:text-foreground'}`}
>
<Activity size={14} />
<span className="hidden sm:inline">{t('brainstorm.activityTitle') || 'Activity'}</span>
</button>
<button
onClick={() => setShowShareDialog(true)}
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-foreground transition-all shadow-sm"
>
<Share2 size={14} />
<span className="hidden sm:inline">{t('brainstorm.invite') || 'Invite'}</span>
</button>
<div className="flex items-center px-3 py-1.5 bg-white dark:bg-white/5 border border-border rounded-xl shadow-sm transition-all hover:border-emerald-500/30">
<div className="flex items-center gap-2 mr-3" title="Live Collaboration">
<div className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]"></span>
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400 hidden sm:inline-block">Live</span>
</div>
<div className="flex items-center group/avatars">
{authSession?.user?.name && (
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#1A1A1A] shadow-sm bg-memento-blue z-10 transition-transform hover:scale-110 hover:z-20"
title={`${authSession.user.name} (You)`}
>
{authSession.user.name.charAt(0).toUpperCase()}
</div>
)}
{socketOthers.slice(0, 3).map((user, idx) => (
<div
key={user.userId}
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#1A1A1A] shadow-sm transition-transform hover:scale-110 hover:z-20"
style={{ backgroundColor: user.color, marginLeft: '-8px', zIndex: 9 - idx }}
title={user.name}
>
{user.name.charAt(0).toUpperCase()}
</div>
))}
{socketOthers.length > 3 && (
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-muted-foreground bg-black/5 dark:bg-white/10 ring-2 ring-white dark:ring-[#1A1A1A] shadow-sm"
style={{ marginLeft: '-8px', zIndex: 5 }}
title={`${socketOthers.length - 3} other participants`}
>
+{socketOthers.length - 3}
</div>
)}
</div>
</div>
</div>
)}
</div>
<div className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-orange-500/20 to-memento-blue/20 rounded-[28px] blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-700" />
<input
type="text"
value={seedInput}
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-orange-400/40 focus:ring-4 focus:ring-orange-500/5"
/>
<button
onClick={() => handleStartBrainstorm()}
disabled={isGenerating || !seedInput.trim()}
className="absolute right-4 top-4 bottom-4 px-6 bg-foreground dark:bg-orange-500 text-background rounded-xl disabled:opacity-50 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2 min-w-[70px] shadow-lg"
>
{isGenerating ? (
<div className="w-6 h-6 border-3 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Plus size={24} />
)}
</button>
</div>
<AnimatePresence>
{createBrainstorm.isPending && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-6 flex items-center gap-4 text-orange-500/80 italic font-serif"
>
<div className="flex gap-1.5">
{[0.2, 0.4, 0.6].map((d, i) => (
<motion.div
key={i}
animate={{ scale: [1, 1.5, 1], opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity, delay: d }}
className="w-1.5 h-1.5 rounded-full bg-orange-500"
/>
))}
</div>
<span className="text-base tracking-tight">
{t('brainstorm.generating') || 'AI is harvesting seeds of thought...'}
</span>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="flex-1 flex overflow-hidden relative">
<div className="flex-1 relative" ref={canvasContainerRef}>
{session && <LiveCursors others={socketOthers} />}
<GhostCursor
isActive={isGenerating || !!aiProcessingNodeId}
targetId={aiProcessingNodeId || (expandIdea.isPending ? selectedIdeaId : null)}
containerRef={canvasContainerRef}
/>
<CursorTrackerEffect containerRef={canvasContainerRef} moveCursor={moveCursor} />
{sessionLoading ? (
<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 ? (
<WaveCanvas
session={session}
onNodeSelect={setSelectedIdeaId}
onPositionUpdate={handlePositionUpdate}
selectedNodeId={selectedIdeaId}
onCreateIdea={handleCreateIdea}
remoteMove={remoteMove}
manualEditTrigger={manualEditCount}
playbackIdeas={playbackIdeas}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
<Wind size={120} strokeWidth={1} className="text-muted-foreground animate-pulse" />
<p className="text-xl font-serif italic text-muted-foreground">
The canvas is waiting for your spark...
</p>
</div>
)}
<AnimatePresence>
{session && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="absolute bottom-6 left-6 flex gap-2"
>
<div className="px-4 py-2 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
{[1, 2, 3].map((w) => (
<div key={w} className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{
backgroundColor: WAVE_COLORS[w]?.border?.replace('border-', '') === 'orange-200' ? '#fb923c' : w === 2 ? '#60a5fa' : '#a78bfa',
boxShadow: `0 0 8px ${w === 1 ? 'rgba(251,146,60,0.6)' : w === 2 ? 'rgba(96,165,250,0.6)' : 'rgba(167,139,250,0.6)'}`,
}}
/>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Wave {w}
</span>
</div>
))}
</div>
{canEdit && (
<button
onClick={() => setManualEditCount((c) => c + 1)}
className="px-6 py-3 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:bg-foreground hover:text-background transition-all"
>
<Plus size={14} />
{t('brainstorm.addManualIdea') || 'Add Manual Idea'}
</button>
)}
</motion.div>
)}
</AnimatePresence>
{session && (
<ActivityFeed
activities={mergedActivities}
isOpen={showActivityFeed}
onToggle={() => setShowActivityFeed(!showActivityFeed)}
t={t}
/>
)}
{session && activeSessionId && (
<PlaybackBar
sessionId={activeSessionId}
onSnapshotSelect={setPlaybackIdeas}
onExitPlayback={() => setPlaybackIdeas(null)}
/>
)}
</div>
<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)]"
>
<div className="p-8 flex-1 overflow-y-auto">
<div className="flex items-center justify-between mb-8">
<div
className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border ${
selectedIdea.waveNumber === 1
? 'border-orange-300 dark:border-orange-700 bg-orange-50 dark:bg-orange-500/15 text-orange-600 dark:text-orange-400'
: selectedIdea.waveNumber === 2
? 'border-memento-blue dark:border-blue-700 bg-memento-blue dark:bg-memento-blue/15 text-memento-blue dark:text-memento-blue'
: 'border-violet-300 dark:border-violet-700 bg-violet-50 dark:bg-violet-500/15 text-violet-600 dark:text-violet-400'
}`}
>
{t('brainstorm.wave') || 'Wave'} {selectedIdea.waveNumber}
</div>
<div className="flex items-center gap-2">
{selectedIdea.status === 'converted' && (
<span className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest bg-emerald-500/10 px-2 py-1 rounded-full">
{t('brainstorm.noteCreated') || 'Note Created'}
</span>
)}
<button
onClick={() => setSelectedIdeaId(null)}
className="p-2 hover:bg-foreground/5 rounded-full transition-colors text-muted-foreground"
>
<ChevronRight size={20} />
</button>
</div>
</div>
<h2 className="text-3xl font-serif font-medium text-foreground mb-2">
{selectedIdea.title}
</h2>
<div className="flex items-center gap-4 mb-8">
<div className="flex items-center gap-1">
<Zap size={14} className="text-orange-500" />
<span className="text-xs font-bold text-muted-foreground">
{t('brainstorm.novelty') || 'Novelty'}: {selectedIdea.noveltyScore || 'N/A'}/10
</span>
</div>
<div className="flex items-center gap-1.5">
{selectedIdea.createdByType === 'human' ? (
<>
<div className="w-4 h-4 rounded-full bg-memento-blue flex items-center justify-center text-[8px] font-bold text-white">
{(selectedIdea as any).creator?.name?.charAt(0)?.toUpperCase() || 'U'}
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
{t('brainstorm.humanIdea') || 'Human'}
</span>
</>
) : (
<>
<span className="text-violet-500 text-xs"></span>
<span className="text-[10px] font-bold uppercase tracking-widest text-violet-500">
{t('brainstorm.aiIdea') || 'AI'}
</span>
</>
)}
</div>
</div>
<p className="text-foreground/80 leading-relaxed font-light mb-10 text-lg">
{selectedIdea.description}
</p>
{selectedIdea.connectionToSeed && (
<div className="p-6 bg-slate-50 dark:bg-white/[0.03] rounded-2xl border border-border/40 mb-10">
<h4 className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-3">
{t('brainstorm.originConnection') || 'Origin connection'}
</h4>
<p className="text-sm italic text-muted-foreground leading-relaxed">
&ldquo;{selectedIdea.connectionToSeed}&rdquo;
</p>
</div>
)}
{selectedIdea.noteRefs && selectedIdea.noteRefs.length > 0 && (
<div className="space-y-3 mb-10">
<h4 className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-1">
{t('brainstorm.ideaOrigin') || 'Origin of the idea'}
</h4>
{selectedIdea.noteRefs.map((ref: BrainstormNoteRef) => {
const isPositive = ref.relation === 'derived_from' || ref.relation === 'extends'
const isNegative = ref.relation === 'opposes'
const badgeColor = isPositive
? 'bg-emerald-500/10 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-700'
: isNegative
? 'bg-rose-500/10 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-700'
: 'bg-amber-500/10 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-700'
const relLabel = t(`brainstorm.${ref.relation}`) || ref.relation
const vis = (ref as any).visibility || 'participants'
const isRestricted = vis === 'owner_only'
return (
<div
key={ref.id}
className="p-4 rounded-xl border border-border bg-white dark:bg-white/5"
>
<div className="flex items-start gap-3">
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border ${badgeColor}`}>
{relLabel}
</span>
{isRestricted && !isGuest && (
<span className="shrink-0 px-1.5 py-0.5 rounded-full text-[8px] font-bold uppercase tracking-wider border border-amber-200 dark:border-amber-700 bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center gap-0.5">
<Lock size={8} /> Owner
</span>
)}
<div className="flex-1 min-w-0">
{ref.note ? (
<p className="text-sm font-medium text-foreground truncate">
{ref.note.title || 'Untitled'}
</p>
) : (
<p className="text-sm italic text-muted-foreground">
{t('brainstorm.noNoteLink') || 'Purely generative idea'}
</p>
)}
<p className="text-xs italic text-muted-foreground mt-1 leading-relaxed">
{ref.explanation}
</p>
</div>
{ref.noteId && (
<button
onClick={() => router.push(`/?openNote=${ref.noteId}`)}
className="shrink-0 px-2 py-1 text-[9px] font-bold uppercase tracking-wider rounded-lg bg-foreground/5 hover:bg-foreground/10 text-muted-foreground hover:text-foreground transition-all"
>
{t('brainstorm.viewNote') || 'View'}
</button>
)}
</div>
</div>
)
})}
</div>
)}
{canEdit && (
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => handleDeepen(selectedIdea)}
disabled={expandIdea.isPending}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-orange-400/40 hover:bg-orange-500/5 transition-all group disabled:opacity-50"
>
<Wind size={24} className="text-muted-foreground group-hover:text-orange-500 mb-2" />
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground group-hover:text-foreground">
{expandIdea.isPending ? (t('brainstorm.deepening') || 'Generating...') : (t('brainstorm.deepen') || 'Deepen')}
</span>
</button>
<button
onClick={() => handleConvert(selectedIdea)}
disabled={selectedIdea.status === 'converted' || convertIdea.isPending}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-emerald-400/40 hover:bg-emerald-500/5 transition-all group disabled:opacity-50"
>
<FileText size={24} className="text-muted-foreground group-hover:text-emerald-500 mb-2" />
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground group-hover:text-foreground">
{convertIdea.isPending ? (t('brainstorm.converting') || 'Converting...') : (t('brainstorm.extract') || 'Create Note')}
</span>
</button>
</div>
<button
onClick={() => handleDismiss(selectedIdea.id)}
className="w-full py-4 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all border border-transparent hover:border-rose-500/10"
>
{t('brainstorm.dismiss') || 'Not pertinent'}
</button>
</div>
)}
{isGuest && (
<div className="mt-6 px-4 py-3 bg-orange-500/5 border border-orange-500/10 rounded-xl flex items-center gap-2">
<Globe size={14} className="text-orange-500 shrink-0" />
<span className="text-[11px] text-orange-600 dark:text-orange-400">
Vous consultez ce brainstorm en tant qu'invité. Connectez-vous pour modifier.
</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
<div className="w-16 border-l border-border flex flex-col items-center py-6 gap-6 bg-white dark:bg-[#1A1A1A] z-10">
<History size={18} className="text-muted-foreground" />
<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-memento-blue dark:bg-memento-blue/10 text-memento-blue hover:bg-blue-100 hover:text-memento-blue'
: '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>
<div className="w-px h-12 bg-border/40" />
</div>
</div>
{activeSessionId && session && (
<BrainstormShareDialog
open={showShareDialog}
onOpenChange={setShowShareDialog}
sessionId={activeSessionId}
seedIdea={session.seedIdea}
isPublic={(session as any).isPublic || false}
guestCanEdit={(session as any).guestCanEdit || false}
/>
)}
<AnimatePresence>
{impactToast && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-foreground text-background rounded-2xl shadow-2xl flex items-center gap-4 text-sm font-medium"
>
<span>
{impactToast.notesEnriched === 0 && impactToast.notesMarkedDry === 0
? (t('brainstorm.linkCopied') || 'Invite link copied!')
: <>
{impactToast.notesEnriched > 0 && `${impactToast.notesEnriched} note(s) enriched`}
{impactToast.notesEnriched > 0 && impactToast.notesMarkedDry > 0 && ' · '}
{impactToast.notesMarkedDry > 0 && `${impactToast.notesMarkedDry} note(s) marked dry`}
</>
}
</span>
<button onClick={() => setImpactToast(null)} className="text-background/60 hover:text-background">
</button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{exportError && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-rose-500 text-white rounded-2xl shadow-2xl flex items-center gap-4 text-sm font-medium"
>
<span>{exportError}</span>
<button onClick={() => setExportError(null)} className="text-white/60 hover:text-white">
</button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{exportToast && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-emerald-600 text-white rounded-2xl shadow-2xl flex items-center gap-3 text-sm font-medium"
>
<Wind size={18} />
<div className="flex flex-col">
<span className="font-bold">{exportToast.noteTitle}</span>
<span className="text-[11px] text-emerald-100">Carnet : {exportToast.notebookName}</span>
</div>
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
Ouverture…
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{convertToast && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-emerald-600 text-white rounded-2xl shadow-2xl flex items-center gap-3 text-sm font-medium"
>
<FileText size={18} />
<div className="flex flex-col">
<span className="font-bold">{convertToast.noteTitle}</span>
<span className="text-[11px] text-emerald-100">{t('brainstorm.noteCreated') || 'Note Created'}</span>
</div>
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
Ouverture
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,296 @@
'use client'
import React, { useState, useTransition, useEffect, useRef, useCallback } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { UserPlus, Check, AlertCircle, Globe, Lock, Copy } from 'lucide-react'
import { createBrainstormShare } from '@/app/actions/brainstorm'
import { useUpdateBrainstormSettings } from '@/hooks/use-brainstorm'
interface BrainstormShareDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
sessionId: string
seedIdea: string
isPublic?: boolean
guestCanEdit?: boolean
}
interface UserResult {
id: string
name: string | null
email: string
image: string | null
}
const MESSAGES: Record<string, { text: string; type: 'success' | 'info' }> = {
invited: { text: 'Invitation envoyée !', type: 'success' },
re_invited: { text: 'Invitation renvoyée !', type: 'success' },
already_shared: {
text: 'Cette personne a déjà accès à ce brainstorm.',
type: 'info',
},
already_pending: {
text: 'Une invitation est déjà en attente pour cette personne.',
type: 'info',
},
}
export function BrainstormShareDialog({
open,
onOpenChange,
sessionId,
seedIdea,
isPublic = false,
guestCanEdit = false,
}: BrainstormShareDialogProps) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<UserResult[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [feedback, setFeedback] = useState<{
text: string
type: 'success' | 'error' | 'info'
} | null>(null)
const [isPending, startTransition] = useTransition()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [publicEnabled, setPublicEnabled] = useState(isPublic)
const [guestEditEnabled, setGuestEditEnabled] = useState(guestCanEdit)
const [linkCopied, setLinkCopied] = useState(false)
const updateSettings = useUpdateBrainstormSettings(sessionId)
useEffect(() => {
setPublicEnabled(isPublic)
setGuestEditEnabled(guestCanEdit)
}, [isPublic, guestCanEdit])
useEffect(() => {
if (!open) {
setQuery('')
setResults([])
setShowDropdown(false)
setFeedback(null)
}
}, [open])
const searchUsers = useCallback(async (q: string) => {
if (q.length < 2) {
setResults([])
setShowDropdown(false)
return
}
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
const data = await res.json()
setResults(data.users || [])
setShowDropdown(data.users?.length > 0)
} catch {
setResults([])
}
}, [])
const handleInputChange = (value: string) => {
setQuery(value)
setFeedback(null)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => searchUsers(value), 200)
}
const selectUser = (user: UserResult) => {
setQuery(user.email)
setShowDropdown(false)
setResults([])
}
const handleShare = (e: React.FormEvent) => {
e.preventDefault()
if (!query.trim()) return
setShowDropdown(false)
setFeedback(null)
startTransition(async () => {
try {
const result = await createBrainstormShare(sessionId, query.trim())
if (result.message && MESSAGES[result.message]) {
const m = MESSAGES[result.message]
setFeedback({ text: m.text, type: m.type })
} else {
setFeedback({ text: 'Invitation envoyée !', type: 'success' })
}
if (result.message === 'invited' || result.message === 're_invited') {
setQuery('')
}
} catch (err: any) {
setFeedback({ text: err.message || 'Erreur', type: 'error' })
}
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<div className="w-7 h-7 rounded-lg bg-orange-500/10 flex items-center justify-center">
<UserPlus size={14} className="text-orange-500" />
</div>
<span className="font-serif">Partager le brainstorm</span>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs italic font-serif truncate">
{seedIdea.length > 60 ? seedIdea.substring(0, 60) + '…' : seedIdea}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleShare} className="space-y-3 mt-2">
<div className="relative" ref={dropdownRef}>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
Rechercher une personne
</label>
<input
type="text"
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => results.length > 0 && setShowDropdown(true)}
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
placeholder="Nom ou email…"
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500/40 transition-all"
autoFocus
/>
{showDropdown && results.length > 0 && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-white dark:bg-[#1A1A1A] border border-border rounded-xl shadow-xl overflow-hidden">
{results.map((user) => (
<button
key={user.id}
type="button"
onMouseDown={(e) => {
e.preventDefault()
selectUser(user)
}}
className="w-full px-4 py-2.5 flex items-center gap-3 hover:bg-orange-500/5 transition-colors text-left"
>
<div className="w-8 h-8 rounded-full bg-orange-500/10 flex items-center justify-center text-xs font-bold text-orange-600 shrink-0">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate">
{user.name || 'Sans nom'}
</p>
<p className="text-[11px] text-muted-foreground truncate">
{user.email}
</p>
</div>
</button>
))}
</div>
)}
</div>
{feedback && (
<div
className={`flex items-center gap-1.5 px-1 text-[11px] font-medium ${
feedback.type === 'success'
? 'text-emerald-500'
: feedback.type === 'error'
? 'text-rose-500'
: 'text-amber-500'
}`}
>
{feedback.type === 'success' ? (
<Check size={12} />
) : (
<AlertCircle size={12} />
)}
{feedback.text}
</div>
)}
<button
type="submit"
disabled={!query.trim() || isPending}
className="w-full py-3 bg-orange-500 hover:bg-orange-600 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
>
<UserPlus size={12} />
{isPending ? 'Envoi…' : 'Partager'}
</button>
</form>
<p className="text-[10px] text-muted-foreground/60 text-center mt-1">
La personne recevra une notification pour accepter ou refuser.
</p>
<div className="border-t border-border mt-4 pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{publicEnabled ? (
<Globe size={14} className="text-emerald-500" />
) : (
<Lock size={14} className="text-muted-foreground" />
)}
<span className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
Lien public
</span>
</div>
<button
onClick={() => {
const next = !publicEnabled
setPublicEnabled(next)
if (!next) setGuestEditEnabled(false)
updateSettings.mutate({ isPublic: next, guestCanEdit: next ? guestEditEnabled : false })
}}
className={`relative w-10 h-5 rounded-full transition-colors ${publicEnabled ? 'bg-emerald-500' : 'bg-border'}`}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${publicEnabled ? 'translate-x-5' : 'translate-x-0.5'}`}
/>
</button>
</div>
{publicEnabled && (
<>
<div className="flex items-center gap-2 mb-3">
<input
readOnly
value={`${typeof window !== 'undefined' ? window.location.origin : ''}/brainstorm?session=${sessionId}`}
className="flex-1 px-3 py-2 text-[11px] bg-foreground/5 border border-border rounded-lg text-muted-foreground truncate"
/>
<button
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/brainstorm?session=${sessionId}`)
setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 2000)
}}
className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider bg-foreground/5 hover:bg-foreground/10 rounded-lg transition-colors flex items-center gap-1"
>
{linkCopied ? <Check size={12} className="text-emerald-500" /> : <Copy size={12} />}
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
Autoriser les invités à modifier
</span>
<button
onClick={() => {
const next = !guestEditEnabled
setGuestEditEnabled(next)
updateSettings.mutate({ guestCanEdit: next })
}}
className={`relative w-10 h-5 rounded-full transition-colors ${guestEditEnabled ? 'bg-orange-500' : 'bg-border'}`}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${guestEditEnabled ? 'translate-x-5' : 'translate-x-0.5'}`}
/>
</button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import React, { useEffect, useState, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/react'
interface GhostCursorProps {
isActive: boolean
containerRef: React.RefObject<HTMLDivElement | null>
targetId?: string | null
}
export function GhostCursor({ isActive, containerRef, targetId }: GhostCursorProps) {
const [position, setPosition] = useState({ x: 0, y: 0 })
const [visible, setVisible] = useState(false)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (!isActive) {
setVisible(false)
if (intervalRef.current) clearInterval(intervalRef.current)
return
}
const container = containerRef.current
if (!container) return
setVisible(true)
// Initialisation sécurisée
const initialRect = container.getBoundingClientRect()
const initialCx = initialRect.width > 0 ? initialRect.width / 2 : window.innerWidth / 2
const initialCy = initialRect.height > 0 ? initialRect.height / 2 : window.innerHeight / 2
setPosition({
x: initialCx + (Math.random() - 0.5) * 200,
y: initialCy + (Math.random() - 0.5) * 200,
})
let angle = Math.random() * Math.PI * 2
let targetX = initialCx + Math.cos(angle) * 250
let targetY = initialCy + Math.sin(angle) * 250
intervalRef.current = setInterval(() => {
// Recalculer les dimensions à chaque tick pour s'adapter aux redimensionnements
const currentContainer = containerRef.current
if (!currentContainer) return
const containerRect = currentContainer.getBoundingClientRect()
const cx = containerRect.width / 2
const cy = containerRect.height / 2
let currentTargetX = targetX;
let currentTargetY = targetY;
if (targetId) {
const nodeElement = document.querySelector(`[data-id="${targetId}"]`);
if (nodeElement) {
const nodeRect = nodeElement.getBoundingClientRect();
currentTargetX = nodeRect.left - containerRect.left + nodeRect.width / 2;
currentTargetY = nodeRect.top - containerRect.top + nodeRect.height / 2;
}
}
setPosition(prev => {
const dx = currentTargetX - prev.x
const dy = currentTargetY - prev.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (!targetId && dist < 20) {
angle = Math.random() * Math.PI * 2
const radius = 150 + Math.random() * 200
targetX = cx + Math.cos(angle) * radius
targetY = cy + Math.sin(angle) * radius
}
const speed = targetId ? 0.15 : 0.06;
let newX = prev.x + dx * speed;
let newY = prev.y + dy * speed;
// Protection Anti-NaN qui bloquait le curseur en haut à gauche
if (isNaN(newX)) newX = cx;
if (isNaN(newY)) newY = cy;
return {
x: newX,
y: newY,
}
})
}, 50)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [isActive, containerRef, targetId])
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
className="absolute pointer-events-none z-50"
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
>
<div className="relative">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M0 0L16 6L8 8L6 16L0 0Z" fill="#a78bfa" />
</svg>
<div className="absolute -top-1 -right-1 w-3 h-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-50" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
</div>
<div className="mt-3 ml-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white whitespace-nowrap shadow-lg bg-gradient-to-r from-violet-500 to-purple-600">
AI
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,210 @@
'use client'
import React, { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { UserPlus, Link, Mail, Check, Copy } from 'lucide-react'
interface InviteDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onInviteByEmail: (email: string, role: 'editor' | 'viewer') => Promise<any>
onInviteByLink: () => Promise<any>
seedIdea: string
isLoading: boolean
t: (key: string) => string | undefined
}
export function InviteDialog({
open,
onOpenChange,
onInviteByEmail,
onInviteByLink,
seedIdea,
isLoading,
t,
}: InviteDialogProps) {
const [tab, setTab] = useState<'email' | 'link'>('email')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'editor' | 'viewer'>('editor')
const [linkCopied, setLinkCopied] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const [localLoading, setLocalLoading] = useState(false)
const handleEmailInvite = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
setLocalLoading(true)
try {
const res = await onInviteByEmail(email.trim(), role)
if (res?.invitedUser) {
setEmailSent(true)
setTimeout(() => {
setEmailSent(false)
setEmail('')
}, 2000)
}
} finally {
setLocalLoading(false)
}
}
const handleLinkCopy = async () => {
setLocalLoading(true)
try {
const res = await onInviteByLink()
if (res?.inviteUrl) {
const url = window.location.origin + res.inviteUrl
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
} else {
const ta = document.createElement('textarea')
ta.value = url
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 3000)
}
} finally {
setLocalLoading(false)
}
}
const busy = isLoading || localLoading
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<div className="w-7 h-7 rounded-lg bg-emerald-500/10 flex items-center justify-center">
<UserPlus size={14} className="text-emerald-500" />
</div>
<span className="font-serif">{t('brainstorm.inviteTitle') || 'Invite to brainstorm'}</span>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs italic font-serif">
{seedIdea.length > 60 ? seedIdea.substring(0, 60) + '…' : seedIdea}
</DialogDescription>
</DialogHeader>
<div className="flex gap-1 p-1 bg-foreground/5 rounded-xl mt-2">
<button
onClick={() => setTab('email')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-[10px] font-bold uppercase tracking-[0.1em] transition-all ${
tab === 'email'
? 'bg-white dark:bg-[#2A2A2A] text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Mail size={12} />
{t('brainstorm.inviteByEmail') || 'Email'}
</button>
<button
onClick={() => setTab('link')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-[10px] font-bold uppercase tracking-[0.1em] transition-all ${
tab === 'link'
? 'bg-white dark:bg-[#2A2A2A] text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Link size={12} />
{t('brainstorm.inviteByLink') || 'Link'}
</button>
</div>
{tab === 'email' ? (
<form onSubmit={handleEmailInvite} className="space-y-4 mt-2">
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.inviteEmailLabel') || 'Email address'}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('brainstorm.inviteEmailPlaceholder') || 'colleague@email.com'}
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/40 transition-all"
autoFocus
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.inviteRoleLabel') || 'Role'}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setRole('editor')}
className={`flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] border transition-all ${
role === 'editor'
? 'border-emerald-500/40 bg-emerald-500/5 text-emerald-600'
: 'border-border text-muted-foreground hover:border-emerald-500/20'
}`}
>
{t('brainstorm.roleEditor') || 'Editor'}
</button>
<button
type="button"
onClick={() => setRole('viewer')}
className={`flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] border transition-all ${
role === 'viewer'
? 'border-memento-blue/40 bg-memento-blue/5 text-memento-blue'
: 'border-border text-muted-foreground hover:border-memento-blue/20'
}`}
>
{t('brainstorm.roleViewer') || 'Viewer'}
</button>
</div>
</div>
<button
type="submit"
disabled={!email.trim() || busy}
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
>
{emailSent ? (
<>
<Check size={12} />
{t('brainstorm.inviteSent') || 'Invitation sent!'}
</>
) : (
<>
<Mail size={12} />
{busy ? '...' : (t('brainstorm.sendInvite') || 'Send invitation')}
</>
)}
</button>
</form>
) : (
<div className="space-y-4 mt-2">
<p className="text-xs text-muted-foreground leading-relaxed">
{t('brainstorm.linkDescription') || 'Anyone with this link can join the brainstorm session.'}
</p>
<button
onClick={handleLinkCopy}
disabled={busy}
className="w-full py-3 bg-foreground/5 hover:bg-foreground/10 text-foreground text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl transition-all flex items-center justify-center gap-1.5 border border-border"
>
{linkCopied ? (
<>
<Check size={12} className="text-emerald-500" />
<span className="text-emerald-500">{t('brainstorm.linkCopied') || 'Link copied!'}</span>
</>
) : (
<>
<Copy size={12} />
{busy ? '...' : (t('brainstorm.copyLink') || 'Copy invite link')}
</>
)}
</button>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import React from 'react'
import { PresenceUser } from '@/hooks/use-brainstorm-socket'
function Cursor({ x, y, name, color }: { x: number; y: number; name: string; color: string }) {
return (
<div
className="absolute pointer-events-none z-50 transition-all duration-75"
style={{ transform: `translate(${x}px, ${y}px)` }}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M0 0L16 6L8 8L6 16L0 0Z" fill={color} />
</svg>
<div
className="mt-3 ml-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white whitespace-nowrap shadow-lg"
style={{ backgroundColor: color }}
>
{name}
</div>
</div>
)
}
export function LiveCursors({ others }: { others: PresenceUser[] }) {
return (
<div className="absolute inset-0 pointer-events-none z-50">
{others.map((user) => {
if (!user.cursor) return null
return (
<Cursor
key={user.userId}
x={user.cursor.x}
y={user.cursor.y}
name={user.name}
color={user.color}
/>
)
})}
</div>
)
}
export function useCursorTracking(
containerRef: React.RefObject<HTMLDivElement | null>,
moveCursor: (cursor: { x: number; y: number } | null) => void
) {
React.useEffect(() => {
const container = containerRef.current
if (!container) return
const handleMove = (e: MouseEvent) => {
const rect = container.getBoundingClientRect()
moveCursor({ x: e.clientX - rect.left, y: e.clientY - rect.top })
}
const handleLeave = () => {
moveCursor(null)
}
container.addEventListener('mousemove', handleMove)
container.addEventListener('mouseleave', handleLeave)
return () => {
container.removeEventListener('mousemove', handleMove)
container.removeEventListener('mouseleave', handleLeave)
}
}, [containerRef, moveCursor])
}
export function PresenceAvatars({ others }: { others: PresenceUser[] }) {
if (others.length === 0) return null
return (
<div className="flex items-center gap-1">
{others.slice(0, 4).map((user) => {
const initial = (user.name || '?').charAt(0).toUpperCase()
return (
<div
key={user.userId}
className="w-6 h-6 rounded-full flex items-center justify-center text-[9px] font-bold text-white border-2 border-white dark:border-zinc-900 shadow-sm"
style={{ backgroundColor: user.color, marginLeft: '-6px' }}
title={user.name}
>
{initial}
</div>
)
})}
{others.length > 4 && (
<span className="text-[9px] text-muted-foreground ml-1">+{others.length - 4}</span>
)}
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import React, { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Lightbulb } from 'lucide-react'
interface ManualIdeaDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (title: string, description?: string) => void
isLoading?: boolean
parentIdeaTitle?: string | null
t: (key: string) => string | undefined
}
export function ManualIdeaDialog({
open,
onOpenChange,
onSubmit,
isLoading,
parentIdeaTitle,
t,
}: ManualIdeaDialogProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
onSubmit(title.trim(), description.trim() || undefined)
setTitle('')
setDescription('')
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<div className="w-7 h-7 rounded-lg bg-memento-blue/10 flex items-center justify-center">
<Lightbulb size={14} className="text-memento-blue" />
</div>
<span className="font-serif">{t('brainstorm.addIdea') || 'Add an idea'}</span>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs">
{parentIdeaTitle
? `${t('brainstorm.respondsTo') || 'Responds to'} « ${parentIdeaTitle.length > 40 ? parentIdeaTitle.substring(0, 40) + '…' : parentIdeaTitle} »`
: (t('brainstorm.manualIdeaDesc') || 'Share your idea with the brainstorm canvas')
}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.manualIdeaTitle') || 'Title'}
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('brainstorm.manualIdeaTitlePlaceholder') || 'Your idea in a few words...'}
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-memento-blue/20 focus:border-memento-blue/40 transition-all font-serif"
autoFocus
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.manualIdeaDescLabel') || 'Description (optional)'}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('brainstorm.manualIdeaDescPlaceholder') || 'Elaborate on your idea...'}
className="w-full h-24 px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-memento-blue/20 focus:border-memento-blue/40 resize-none transition-all"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="px-4 py-2 text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground hover:text-foreground rounded-xl hover:bg-foreground/5 transition-all"
>
{t('brainstorm.cancel') || 'Cancel'}
</button>
<button
type="submit"
disabled={!title.trim() || isLoading}
className="px-5 py-2.5 bg-memento-blue hover:bg-memento-blue text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Lightbulb size={12} />
{isLoading ? (t('brainstorm.adding') || 'Adding...') : (t('brainstorm.addIdea') || 'Add idea')}
</button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,197 @@
'use client'
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Play, Pause, SkipBack, SkipForward, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react'
import { useBrainstormSnapshots } from '@/hooks/use-brainstorm'
interface SnapshotIdea {
id: string
title: string
waveNumber: number
positionX: number | null
positionY: number | null
parentIdeaId: string | null
noveltyScore: number | null
createdByType: string | null
status: string
}
interface Snapshot {
id: string
step: number
label: string | null
ideaGraph: string
createdAt: string
}
interface PlaybackBarProps {
sessionId: string
onSnapshotSelect: (ideas: SnapshotIdea[]) => void
onExitPlayback: () => void
}
export function PlaybackBar({ sessionId, onSnapshotSelect, onExitPlayback }: PlaybackBarProps) {
const { data: snapshots } = useBrainstormSnapshots(sessionId)
const [isOpen, setIsOpen] = useState(false)
const [currentStep, setCurrentStep] = useState(-1)
const [isPlaying, setIsPlaying] = useState(false)
const playIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const parsedSnapshots: (Snapshot & { ideas: SnapshotIdea[] })[] = React.useMemo(() => {
return (snapshots || []).map((s: Snapshot) => ({
...s,
ideas: JSON.parse(s.ideaGraph) as SnapshotIdea[],
}))
}, [snapshots])
const handleStepChange = useCallback((step: number) => {
if (step === -1) {
setCurrentStep(-1)
onExitPlayback()
return
}
const snapshot = parsedSnapshots[step]
if (snapshot) {
setCurrentStep(step)
onSnapshotSelect(snapshot.ideas)
}
}, [parsedSnapshots, onSnapshotSelect, onExitPlayback])
const togglePlay = useCallback(() => {
if (isPlaying) {
setIsPlaying(false)
if (playIntervalRef.current) clearInterval(playIntervalRef.current)
return
}
setIsPlaying(true)
const startStep = currentStep === -1 ? 0 : currentStep + 1
let step = startStep
playIntervalRef.current = setInterval(() => {
if (step >= parsedSnapshots.length) {
setIsPlaying(false)
if (playIntervalRef.current) clearInterval(playIntervalRef.current)
return
}
handleStepChange(step)
step++
}, 1500)
}, [isPlaying, currentStep, parsedSnapshots.length, handleStepChange])
useEffect(() => {
return () => {
if (playIntervalRef.current) clearInterval(playIntervalRef.current)
}
}, [])
if (!parsedSnapshots || parsedSnapshots.length === 0) return null
const isLive = currentStep === -1
return (
<AnimatePresence>
<motion.div
initial={{ y: 60 }}
animate={{ y: isOpen ? 0 : 40 }}
className="absolute bottom-0 left-0 right-16 z-30"
>
<div className="mx-6 mb-4 bg-white/90 dark:bg-[#1A1A1A]/90 backdrop-blur-xl border border-border rounded-2xl shadow-2xl overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full px-5 py-2.5 flex items-center justify-between hover:bg-foreground/5 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${isLive ? 'bg-emerald-500 animate-pulse' : isPlaying ? 'bg-orange-500 animate-pulse' : 'bg-muted-foreground'}`} />
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{isLive ? 'Live' : `Step ${currentStep + 1}/${parsedSnapshots.length}`}
</span>
{parsedSnapshots[currentStep]?.label && (
<span className="text-[10px] text-muted-foreground/60 truncate max-w-[200px]">
{parsedSnapshots[currentStep].label}
</span>
)}
</div>
<div className="flex items-center gap-2">
{isLive ? (
<span className="text-[9px] font-bold text-emerald-500 uppercase tracking-wider">Live</span>
) : (
<button
onClick={(e) => { e.stopPropagation(); handleStepChange(-1) }}
className="p-1.5 hover:bg-foreground/10 rounded-lg transition-colors"
title="Return to live"
>
<RotateCcw size={12} className="text-muted-foreground" />
</button>
)}
{isOpen ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</div>
</button>
{isOpen && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
className="border-t border-border"
>
<div className="px-5 py-3 flex items-center gap-3">
<button
onClick={() => handleStepChange(Math.max(0, currentStep - 1))}
disabled={currentStep <= 0}
className="p-2 hover:bg-foreground/5 rounded-lg transition-colors disabled:opacity-30"
>
<SkipBack size={14} />
</button>
<button
onClick={togglePlay}
className="p-2 hover:bg-foreground/5 rounded-lg transition-colors"
>
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
onClick={() => handleStepChange(Math.min(parsedSnapshots.length - 1, currentStep + 1))}
disabled={currentStep >= parsedSnapshots.length - 1}
className="p-2 hover:bg-foreground/5 rounded-lg transition-colors disabled:opacity-30"
>
<SkipForward size={14} />
</button>
<div className="flex-1 mx-3">
<input
type="range"
min={-1}
max={parsedSnapshots.length - 1}
value={currentStep}
onChange={(e) => handleStepChange(parseInt(e.target.value))}
className="w-full h-1.5 bg-border rounded-full appearance-none cursor-pointer accent-orange-500"
/>
<div className="flex justify-between mt-1">
<span className="text-[8px] text-muted-foreground/40">Live</span>
<span className="text-[8px] text-muted-foreground/40">{parsedSnapshots.length} steps</span>
</div>
</div>
</div>
<div className="px-5 pb-3 flex gap-1 overflow-x-auto">
{parsedSnapshots.map((s, idx) => (
<button
key={s.id}
onClick={() => handleStepChange(idx)}
className={`shrink-0 px-2.5 py-1 rounded-lg text-[9px] font-bold uppercase tracking-wider transition-all ${
idx === currentStep
? 'bg-orange-500 text-white'
: 'bg-foreground/5 text-muted-foreground hover:bg-foreground/10'
}`}
>
{s.label ? (s.label.length > 20 ? s.label.substring(0, 20) + '…' : s.label) : `#${idx + 1}`}
</button>
))}
</div>
</motion.div>
)}
</div>
</motion.div>
</AnimatePresence>
)
}

View File

@@ -0,0 +1,585 @@
'use client'
import React, { useEffect, useRef, useState, useCallback } from 'react'
import * as d3 from 'd3'
import { BrainstormSession } from '@/types/brainstorm'
interface WaveCanvasProps {
session: BrainstormSession
onNodeSelect: (id: string) => void
onPositionUpdate?: (id: string, pos: { x: number; y: number }) => void
selectedNodeId: string | null
onCreateIdea?: (data: { title: string; parentIdeaId?: string; x: number; y: number }) => void
remoteMove?: { ideaId: string; x: number; y: number; _seq: number } | null
manualEditTrigger?: number
playbackIdeas?: any[] | null
}
const WAVE_COLORS: Record<number, string> = {
1: '#fb923c',
2: '#60a5fa',
3: '#a78bfa',
}
export const WaveCanvas: React.FC<WaveCanvasProps> = ({
session,
onNodeSelect,
onPositionUpdate,
selectedNodeId,
onCreateIdea,
remoteMove,
manualEditTrigger,
playbackIdeas,
}) => {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const nodeRef = useRef<d3.Selection<SVGGElement, any, SVGGElement, unknown> | null>(null)
const linkRef = useRef<d3.Selection<SVGLineElement, any, SVGGElement, unknown> | null>(null)
const simulationRef = useRef<d3.Simulation<any, any> | null>(null)
const transformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity)
const onNodeSelectRef = useRef(onNodeSelect)
onNodeSelectRef.current = onNodeSelect
const onPositionUpdateRef = useRef(onPositionUpdate)
onPositionUpdateRef.current = onPositionUpdate
const onCreateIdeaRef = useRef(onCreateIdea)
onCreateIdeaRef.current = onCreateIdea
const ideasKey = session?.ideas?.map(i => `${i.id}:${i.status}:${i.positionX}:${i.positionY}`).join('|') || ''
const sessionId = session?.id || ''
const [isDark, setIsDark] = useState(false)
useEffect(() => {
const el = document.documentElement
setIsDark(el.classList.contains('dark'))
const observer = new MutationObserver(() => {
setIsDark(el.classList.contains('dark'))
})
observer.observe(el, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
const [editingNode, setEditingNode] = useState<{
x: number
y: number
parentId: string | null
} | null>(null)
const [editText, setEditText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editingNode && inputRef.current) {
inputRef.current.focus()
}
}, [editingNode])
useEffect(() => {
if (!manualEditTrigger || manualEditTrigger === 0) return
const container = containerRef.current
if (!container) return
const rect = container.getBoundingClientRect()
setEditingNode({
x: rect.width / 2,
y: rect.height / 2,
parentId: null,
})
setEditText('')
}, [manualEditTrigger])
const toSvgCoords = useCallback((clientX: number, clientY: number) => {
const svg = svgRef.current
if (!svg) return { x: 0, y: 0 }
const pt = svg.createSVGPoint()
pt.x = clientX
pt.y = clientY
const ctm = svg.getScreenCTM()
if (!ctm) return { x: 0, y: 0 }
const svgPt = pt.matrixTransform(ctm.inverse())
const t = transformRef.current
return {
x: (svgPt.x - t.x) / t.k,
y: (svgPt.y - t.y) / t.k,
}
}, [])
const toScreenCoords = useCallback((svgX: number, svgY: number) => {
const svg = svgRef.current
if (!svg) return { x: 0, y: 0 }
const t = transformRef.current
const screenX = svgX * t.k + t.x
const screenY = svgY * t.k + t.y
const ctm = svg.getScreenCTM()
if (!ctm) return { x: 0, y: 0 }
const pt = svg.createSVGPoint()
pt.x = screenX
pt.y = screenY
const screenPt = pt.matrixTransform(ctm)
return { x: screenPt.x, y: screenPt.y }
}, [])
const handleSubmitIdea = useCallback(() => {
if (!editText.trim() || !editingNode || !onCreateIdeaRef.current) return
onCreateIdeaRef.current({
title: editText.trim(),
parentIdeaId: editingNode.parentId || undefined,
x: editingNode.x,
y: editingNode.y,
})
setEditingNode(null)
setEditText('')
}, [editText, editingNode])
useEffect(() => {
if (!svgRef.current || !containerRef.current) return
if (!session?.id || !session?.ideas) return
const activeIdeas = playbackIdeas
? playbackIdeas.map((pi: any) => ({
...pi,
description: '',
connectionToSeed: null,
convertedToNoteId: null,
relatedNoteIds: null,
createdBy: null,
noteRefs: [],
creator: null,
createdAt: new Date(),
sessionId: session.id,
}))
: session.ideas
const width = containerRef.current.clientWidth
const height = containerRef.current.clientHeight
const centerX = width / 2
const centerY = height / 2
const svg = d3.select(svgRef.current)
svg.selectAll('*').remove()
const g = svg.append('g')
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 5])
.on('zoom', (event) => {
g.attr('transform', event.transform)
transformRef.current = event.transform
})
svg.call(zoom)
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8))
interface D3Node extends d3.SimulationNodeDatum {
id: string
type: 'root' | 'idea'
wave?: number
title: string
color: string
radius: number
status?: string
createdByType?: string | null
creatorInitial?: string
}
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
source: string | D3Node
target: string | D3Node
type: 'wave' | 'parent'
}
const nodes: D3Node[] = []
const links: D3Link[] = []
nodes.push({
id: 'root',
type: 'root',
title: session.seedIdea,
color: '#141414',
radius: 40,
fx: 0,
fy: 0,
})
activeIdeas.forEach((idea) => {
const creator = (idea as any).creator as { name: string | null } | undefined | null
nodes.push({
id: idea.id,
type: 'idea',
wave: idea.waveNumber,
title: idea.title,
color: WAVE_COLORS[idea.waveNumber as 1 | 2 | 3] || '#94a3b8',
radius: idea.status === 'dismissed' ? 18 : 28,
status: idea.status,
createdByType: idea.createdByType || 'ai',
creatorInitial: creator?.name ? creator.name.charAt(0).toUpperCase() : undefined,
x: idea.positionX ?? undefined,
y: idea.positionY ?? undefined,
})
if (idea.parentIdeaId) {
links.push({ source: idea.parentIdeaId, target: idea.id, type: 'parent' })
} else {
links.push({ source: 'root', target: idea.id, type: 'wave' })
}
})
const simulation = d3
.forceSimulation<D3Node>(nodes)
.force(
'link',
d3
.forceLink<D3Node, D3Link>(links)
.id((d) => d.id)
.distance((d) => {
if (d.type === 'wave') {
const targetNode = nodes.find(
(n) =>
n.id ===
(typeof d.target === 'string' ? d.target : (d.target as any).id)
)
return (targetNode?.wave || 1) * 200
}
if (d.type === 'parent') return 180
return 100
})
)
.force('charge', d3.forceManyBody().strength(-800))
.force(
'radial',
d3
.forceRadial<D3Node>(
(d) => {
if (d.type === 'root') return 0
return (d.wave || 1) * 200
},
0,
0
)
.strength(0.8)
)
.force('collision', d3.forceCollide<D3Node>().radius((d) => d.radius + 30))
simulationRef.current = simulation
const ringRadii = [200, 400, 600]
g.selectAll('.ring')
.data(ringRadii)
.enter()
.append('circle')
.attr('class', 'ring')
.attr('r', (d) => d)
.attr('fill', 'none')
.attr('stroke', isDark ? '#ffffff10' : '#e2e8f0')
.attr('stroke-width', 1)
.attr('stroke-dasharray', '4,4')
.style('opacity', 0.5)
const link = g
.append('g')
.selectAll('line')
.data(links)
.enter()
.append('line')
.attr('stroke', (d) =>
d.type === 'wave' ? (isDark ? '#334155' : '#cbd5e1') : (isDark ? '#854d0e' : '#fde047')
)
.attr('stroke-width', (d) => (d.type === 'wave' ? 1.5 : 2))
.attr('stroke-dasharray', (d) => (d.type === 'parent' ? 'none' : '4,4'))
linkRef.current = link
const node = g
.append('g')
.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', (d) => `node${d.status === 'dismissed' ? ' dismissed' : ''}`)
.style('cursor', 'pointer')
.style('opacity', (d) => d.status === 'dismissed' ? 0.3 : 1)
.attr('data-id', (d) => d.id)
.on('click', (_event, d) => {
if (d.type === 'idea') onNodeSelectRef.current?.(d.id)
})
.on('dblclick', (event, d) => {
if (!onCreateIdeaRef.current) return
event.stopPropagation()
const parentId = d.type === 'root' ? null : d.id
const angle = Math.random() * Math.PI * 2
const dist = 180
const nx = (d.x || 0) + Math.cos(angle) * dist
const ny = (d.y || 0) + Math.sin(angle) * dist
const screen = toScreenCoords(nx, ny)
const container = containerRef.current
if (!container) return
const rect = container.getBoundingClientRect()
setEditingNode({ x: screen.x - rect.left, y: screen.y - rect.top, parentId })
setEditText('')
})
.call(
d3
.drag<SVGGElement, D3Node>()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0)
d.fx = null
d.fy = null
if (d.type === 'idea' && onPositionUpdateRef.current) {
onPositionUpdateRef.current(d.id, { x: event.x, y: event.y })
}
}) as any
)
nodeRef.current = node
node
.append('circle')
.attr('r', (d) => d.radius)
.attr('fill', (d) =>
d.status === 'converted'
? (isDark ? '#064e3b' : '#ecfdf5')
: d.type === 'root'
? (isDark ? '#f97316' : '#141414')
: (isDark ? '#1e1e1e' : '#fff')
)
.attr('stroke', (d) =>
d.status === 'converted' ? '#10b981' : d.color
)
.attr('stroke-width', 2)
node
.append('text')
.attr('dy', (d) =>
d.type === 'root' ? '.35em' : d.radius + 20
)
.attr('text-anchor', 'middle')
.attr('fill', (d) =>
d.type === 'root'
? '#fff'
: d.status === 'dismissed'
? (isDark ? '#475569' : '#94a3b8')
: (isDark ? '#e2e8f0' : '#141414')
)
.attr(
'class',
(d) =>
d.type === 'root'
? 'text-[10px] font-bold pointer-events-none tracking-widest'
: 'text-[11px] font-bold uppercase tracking-tight pointer-events-none'
)
.text((d) =>
d.type === 'root'
? 'SEED'
: d.title.length > 18
? d.title.substring(0, 18) + '...'
: d.title
)
node
.filter((d) => d.status === 'converted')
.append('text')
.attr('x', (d) => d.radius * 0.55)
.attr('y', (d) => -d.radius * 0.55)
.attr('text-anchor', 'middle')
.attr('fill', isDark ? '#34d399' : '#10b981')
.attr('font-size', '14px')
.attr('class', 'pointer-events-none')
.text('✓')
node
.filter((d) => {
if (!d.wave || d.type === 'root') return false
try {
const ids: string[] = JSON.parse(
(session.ideas.find((i) => i.id === d.id)?.relatedNoteIds) || '[]'
)
return ids.length > 0
} catch { return false }
})
.append('text')
.attr('x', (d) => -d.radius * 0.55)
.attr('y', (d) => -d.radius * 0.55)
.attr('text-anchor', 'middle')
.attr('fill', isDark ? '#94a3b8' : '#64748b')
.attr('font-size', '12px')
.attr('class', 'pointer-events-none')
.text('📎')
node
.filter((d) => d.type === 'idea' && d.createdByType === 'human')
.append('circle')
.attr('cx', (d) => d.radius * 0.6)
.attr('cy', (d) => d.radius * 0.6)
.attr('r', 7)
.attr('fill', '#3b82f6')
.attr('stroke', isDark ? '#1e1e1e' : '#fff')
.attr('stroke-width', 1.5)
.attr('class', 'pointer-events-none')
node
.filter((d) => d.type === 'idea' && d.createdByType === 'human')
.append('text')
.attr('x', (d) => d.radius * 0.6)
.attr('y', (d) => d.radius * 0.6 + 3.5)
.attr('text-anchor', 'middle')
.attr('fill', '#fff')
.attr('font-size', '8px')
.attr('font-weight', 'bold')
.attr('class', 'pointer-events-none')
.text((d) => d.creatorInitial || 'U')
node
.filter((d) => d.type === 'idea' && d.createdByType === 'ai')
.append('text')
.attr('x', (d) => d.radius * 0.55)
.attr('y', (d) => -d.radius * 0.55 + 4)
.attr('text-anchor', 'middle')
.attr('fill', '#a78bfa')
.attr('font-size', '10px')
.attr('class', 'pointer-events-none')
.text('✦')
g.append('text')
.attr('text-anchor', 'middle')
.attr('dy', 80)
.attr('class', 'text-lg font-serif italic pointer-events-none')
.attr('fill', isDark ? '#94a3b8' : '#333')
.text(session.seedIdea.length > 60 ? session.seedIdea.substring(0, 60) + '...' : session.seedIdea)
svg.on('dblclick', (event) => {
if (!onCreateIdeaRef.current) return
if ((event.target as Element).closest('.node')) return
const container = containerRef.current
if (!container) return
const rect = container.getBoundingClientRect()
setEditingNode({ x: event.clientX - rect.left, y: event.clientY - rect.top, parentId: null })
setEditText('')
})
simulation.on('tick', () => {
link
.attr('x1', (d) => (d.source as any).x)
.attr('y1', (d) => (d.source as any).y)
.attr('x2', (d) => (d.target as any).x)
.attr('y2', (d) => (d.target as any).y)
node.attr('transform', (d) => `translate(${d.x},${d.y})`)
})
return () => {
simulation.stop()
}
}, [sessionId, ideasKey, isDark, toSvgCoords, toScreenCoords, playbackIdeas])
useEffect(() => {
if (!nodeRef.current) return
nodeRef.current.selectAll('circle')
.attr('stroke-width', (d: any) => (d.id === selectedNodeId ? 4 : 2))
.style('filter', (d: any) =>
d.id === selectedNodeId
? `drop-shadow(0 0 12px ${d.color}cc)`
: 'none'
)
}, [selectedNodeId])
useEffect(() => {
if (!remoteMove || !nodeRef.current || !linkRef.current || !simulationRef.current) return
const sim = simulationRef.current
const node = nodeRef.current
const link = linkRef.current
node.each(function(d: any) {
if (d.id === remoteMove.ideaId) {
d.fx = remoteMove.x
d.fy = remoteMove.y
}
})
sim.alpha(0.3).restart()
for (let i = 0; i < 30; i++) sim.tick()
sim.stop()
link
.attr('x1', (d: any) => d.source.x)
.attr('y1', (d: any) => d.source.y)
.attr('x2', (d: any) => d.target.x)
.attr('y2', (d: any) => d.target.y)
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
}, [remoteMove])
return (
<div
ref={containerRef}
className="w-full h-full relative cursor-grab active:cursor-grabbing bg-[#F8F7F2] dark:bg-[#0A0A0A] bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#ffffff10_1px,transparent_1px)] [background-size:20px_20px]"
>
<svg ref={svgRef} className="w-full h-full" />
{editingNode && (
<div
className="absolute z-50 pointer-events-auto"
style={{
left: editingNode.x,
top: editingNode.y,
transform: 'translate(-50%, 12px)',
}}
>
<div className="w-[260px] bg-white dark:bg-[#1A1A1A] rounded-2xl shadow-2xl border border-black/10 dark:border-white/10 overflow-hidden">
<div className="px-3.5 pt-3 pb-1 flex items-center gap-2">
<div className="w-5 h-5 rounded-md bg-memento-blue/10 flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2.5" strokeLinecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</div>
<span className="text-[10px] font-bold uppercase tracking-[0.15em] text-foreground/50">
{editingNode.parentId ? 'Réponse' : 'Nouvelle idée'}
</span>
</div>
<div className="px-3.5 pb-3">
<input
ref={inputRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && editText.trim()) handleSubmitIdea()
if (e.key === 'Escape') { setEditingNode(null); setEditText('') }
}}
placeholder={editingNode.parentId ? 'Votre réponse…' : 'Votre idée…'}
className="w-full px-3 py-2.5 text-sm font-serif bg-black/[0.03] dark:bg-white/[0.06] border border-black/[0.06] dark:border-white/[0.12] rounded-xl outline-none focus:border-memento-blue/50 focus:bg-white dark:focus:bg-white/[0.08] transition-all placeholder:text-foreground/25 text-foreground"
/>
<div className="flex items-center justify-between mt-1.5 px-0.5">
<div className="flex items-center gap-2.5">
<kbd className="text-[9px] text-foreground/30 font-mono bg-black/[0.04] px-1.5 py-0.5 rounded"></kbd>
<span className="text-[9px] text-foreground/25">enregistrer</span>
<kbd className="text-[9px] text-foreground/30 font-mono bg-black/[0.04] px-1.5 py-0.5 rounded">esc</kbd>
<span className="text-[9px] text-foreground/25">annuler</span>
</div>
{editingNode.parentId && (
<span className="text-[9px] font-medium text-memento-blue/60 flex items-center gap-0.5">
enfant
</span>
)}
</div>
</div>
</div>
<div className="flex justify-center -mt-[1px]">
<div className="w-3 h-3 bg-white dark:bg-[#1A1A1A] border-r border-b border-black/10 dark:border-white/10 rotate-45 -translate-y-1.5" />
</div>
</div>
)}
<div className="absolute bottom-6 left-6 pointer-events-none">
<p className="text-[10px] font-bold tracking-[0.3em] uppercase text-gray-400 dark:text-gray-600 opacity-60">
Double-clic pour ajouter une idée
</p>
</div>
</div>
)
}

View File

@@ -93,6 +93,19 @@ const ACTION_IDS = [
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string, _format?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
]
/** API language names sent to `/api/ai/reformulate` for translate targets */
const TRANSLATE_LANGUAGE_OPTIONS: { api: string; labelKey: string }[] = [
{ api: 'French', labelKey: 'languages.targets.french' },
{ api: 'English', labelKey: 'languages.targets.english' },
{ api: 'Spanish', labelKey: 'languages.targets.spanish' },
{ api: 'German', labelKey: 'languages.targets.german' },
{ api: 'Persian', labelKey: 'languages.targets.persian' },
{ api: 'Portuguese', labelKey: 'languages.targets.portuguese' },
{ api: 'Italian', labelKey: 'languages.targets.italian' },
{ api: 'Chinese', labelKey: 'languages.targets.chinese' },
{ api: 'Japanese', labelKey: 'languages.targets.japanese' },
]
// ── Types ─────────────────────────────────────────────────────────────────────
interface GenerateResult {
@@ -238,8 +251,8 @@ export function ContextualAIChat({
tone: selectedTone,
images: noteImages || [],
}
body.noteId = noteId
} else if (chatScope !== 'all') {
// scope is a notebook ID
body.notebookId = chatScope
}
return body
@@ -297,7 +310,7 @@ export function ContextualAIChat({
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
).join('\n\n')
if (data.combinedSummary) {
resultText += `\n\n---\n**Résumé:** ${data.combinedSummary}`
resultText += `\n\n---\n${t('ai.inlineSummaryMarkdown')} ${data.combinedSummary}`
}
setActionPreview({ label: t(action.i18nKey), text: resultText })
} catch (e: any) {
@@ -355,7 +368,7 @@ export function ContextualAIChat({
setGenerateResult(null)
const toastId = mToast.loading(
type === 'slides' ? '⏳ Génération de la présentation...' : '⏳ Génération du diagramme...',
type === 'slides' ? t('ai.generateSlidesLoading') : t('ai.generateDiagramLoading'),
{ duration: Infinity }
)
@@ -373,7 +386,7 @@ export function ContextualAIChat({
})
const data = await res.json()
if (!res.ok || !data.success) {
mToast.error(data.error || 'Erreur', { id: toastId })
mToast.error(data.error || t('ai.errorShort'), { id: toastId })
setGenerateLoading(null)
return
}
@@ -391,17 +404,17 @@ export function ContextualAIChat({
generatePollRef.current = null
setGenerateLoading(null)
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
mToast.success('Prêt !', { id: toastId })
mToast.success(t('ai.readyToast'), { id: toastId })
} else if (poll.status === 'failure') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
setGenerateLoading(null)
mToast.error(poll.error || 'Erreur', { id: toastId })
mToast.error(poll.error || t('ai.errorShort'), { id: toastId })
}
} catch { }
}, 3000)
} catch {
mToast.error('Erreur', { id: toastId })
mToast.error(t('ai.errorShort'), { id: toastId })
setGenerateLoading(null)
}
}
@@ -516,7 +529,7 @@ export function ContextualAIChat({
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erreur IA')
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
setResourcePreview({ text: data.enrichedContent, source: mode })
} catch (e: any) {
mToast.error(e.message || t('ai.resource.enrichErrorShort'))
@@ -583,9 +596,9 @@ export function ContextualAIChat({
<div className="flex border-b border-border shrink-0 px-2">
{[
{ id: 'actions', label: 'Actions', icon: <Sparkles size={16} /> },
{ id: 'chat', label: 'Discussion', icon: <MessageSquare size={16} /> },
{ id: 'resource', label: 'Ressource', icon: <Link2 size={16} /> },
{ id: 'actions' as const, label: t('ai.assistantTabActions'), icon: <Sparkles size={16} /> },
{ id: 'chat' as const, label: t('ai.chatTab'), icon: <MessageSquare size={16} /> },
{ id: 'resource' as const, label: t('ai.resourceTab'), icon: <Link2 size={16} /> },
].map(tab => (
<button
key={tab.id}
@@ -627,7 +640,7 @@ export function ContextualAIChat({
<div className="absolute inset-0 z-20 flex flex-col bg-memento-paper/95 dark:bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
<div className="px-6 py-4 border-b border-border/40 flex items-center justify-between shrink-0">
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
{resourcePreview.source === 'chat' ? 'Injecter depuis Discussion' : 'Aperçu IA'}
{resourcePreview.source === 'chat' ? t('ai.resourcePreviewInjectFromChat') : t('ai.resourcePreviewAiTitle')}
</p>
<button onClick={() => setResourcePreview(null)} className="text-foreground/40 hover:text-foreground">
<X size={18} />
@@ -661,7 +674,7 @@ export function ContextualAIChat({
<div className="w-20 h-20 rounded-full bg-card/40 backdrop-blur-sm border border-dashed border-border flex items-center justify-center shadow-sm">
<MessageSquare size={32} className="text-memento-blue/60" />
</div>
<p className="text-xs font-serif italic text-foreground/40 leading-relaxed max-w-[200px]">Posez une question à l'Assistant pour commencer.</p>
<p className="text-xs font-serif italic text-foreground/40 leading-relaxed max-w-[200px]">{t('ai.askToStart')}</p>
</div>
)}
{messages.map((msg: UIMessage) => {
@@ -676,9 +689,9 @@ export function ContextualAIChat({
</div>
{isAssistant && onApplyToNote && (hoveredMsgId === msg.id || messages.at(-1)?.id === msg.id) && (
<div className="flex gap-2 mt-3 opacity-0 group-hover:opacity-100 transition-all">
<button onClick={() => handleInjectFromChat(content, 'replace')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-foreground text-background hover:opacity-90">REPLACER</button>
<button onClick={() => handleInjectFromChat(content, 'complete')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">COMPLÉTER</button>
<button onClick={() => handleInjectFromChat(content, 'merge')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">FUSIONNER</button>
<button onClick={() => handleInjectFromChat(content, 'replace')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-foreground text-background hover:opacity-90">{t('ai.injectReplace')}</button>
<button onClick={() => handleInjectFromChat(content, 'complete')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">{t('ai.injectComplete')}</button>
<button onClick={() => handleInjectFromChat(content, 'merge')} className="px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-widest bg-card/40 backdrop-blur-sm border border-border text-foreground hover:bg-card/60">{t('ai.injectMerge')}</button>
</div>
)}
</div>
@@ -699,7 +712,7 @@ export function ContextualAIChat({
<div className="px-6 py-8 border-t border-border shrink-0 space-y-6">
<div className="flex flex-col gap-4">
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">CONTEXTE</label>
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">{t('ai.chatPanelContext')}</label>
<div className="flex flex-col gap-2">
<button
onClick={() => setChatScope('note')}
@@ -709,19 +722,19 @@ export function ContextualAIChat({
)}
>
<BookOpen size={14} className="text-blueprint/60" />
<span>{t('ai.activeNote') || 'Cette note'}</span>
<span className="ml-auto text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold">Auto</span>
<span>{t('ai.thisNote')}</span>
<span className="ml-auto text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold">{t('ai.scopeAutoBadge')}</span>
</button>
<div className="flex items-center gap-2 px-2">
<div className="h-px flex-1 bg-border/40" />
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">+ Carnet</span>
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">{t('ai.chatPanelNotebookPlus')}</span>
<div className="h-px flex-1 bg-border/40" />
</div>
<HierarchicalNotebookSelector
notebooks={(notebooks || []).filter(nb => !nb.trashedAt)}
selectedId={chatScope !== 'note' && chatScope !== 'all' ? chatScope : null}
onSelect={(id) => setChatScope(id)}
placeholder="Inclure un carnet..."
placeholder={t('ai.chatNotebookSelectPlaceholder')}
className="w-full"
size="sm"
dropUp
@@ -729,7 +742,7 @@ export function ContextualAIChat({
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">TON D'ÉCRITURE</label>
<label className="text-[10px] uppercase tracking-[0.25em] font-bold text-foreground/40 px-1">{t('ai.chatPanelWritingTone')}</label>
<div className="grid grid-cols-4 gap-1.5">
{TONES.map((tone) => {
const Icon = tone.icon
@@ -758,7 +771,7 @@ export function ContextualAIChat({
<textarea
rows={4}
className="w-full bg-card/60 border border-border rounded-2xl p-5 pr-14 text-sm outline-none focus:border-memento-blue transition-all resize-none leading-relaxed font-light custom-scrollbar shadow-sm text-foreground"
placeholder="Posez votre question sur cette note..."
placeholder={t('ai.chatNoteQuestionPlaceholder')}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } }}
@@ -768,7 +781,7 @@ export function ContextualAIChat({
<button
onClick={() => setWebSearch(!webSearch)}
className={cn("p-2.5 rounded-xl transition-colors", webSearch ? "text-memento-blue bg-memento-blue/10" : "text-foreground/20 hover:text-foreground")}
title="Web Search"
title={t('ai.webSearchLabel')}
>
<Globe size={18} />
</button>
@@ -777,7 +790,7 @@ export function ContextualAIChat({
</button>
</div>
</div>
<p className="text-[9px] text-foreground/30 text-center mt-2 uppercase tracking-[0.2em] font-bold italic">Maj+Entrée = nouvelle ligne</p>
<p className="text-[9px] text-foreground/30 text-center mt-2 uppercase tracking-[0.2em] font-bold italic">{t('ai.newLineHint')}</p>
</div>
</motion.div>
)}
@@ -855,18 +868,18 @@ export function ContextualAIChat({
>
<div className="mt-2 p-5 bg-card/40 backdrop-blur-sm border border-memento-blue/30 rounded-2xl space-y-5 shadow-sm">
<div className="grid grid-cols-3 gap-2">
{['Français', 'English', 'Español', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais'].map((lang) => (
{TRANSLATE_LANGUAGE_OPTIONS.map(({ api, labelKey }) => (
<button
key={lang}
onClick={() => setTranslateTarget(lang)}
key={api}
onClick={() => setTranslateTarget(api)}
className={cn(
"py-2 px-1 rounded-lg border text-[10px] font-bold uppercase tracking-tighter transition-all",
translateTarget === lang
? "bg-memento-blue border-memento-blue text-white shadow-md shadow-memento-blue/20"
translateTarget === api
? "bg-memento-blue border-memento-blue text-white shadow-md shadow-memento-blue/20"
: "bg-card/60 border-border text-foreground/60 hover:border-foreground/20"
)}
>
{lang}
{t(labelKey)}
</button>
))}
</div>
@@ -880,7 +893,7 @@ export function ContextualAIChat({
setCustomLangInput(e.target.value)
setTranslateTarget(e.target.value)
}}
placeholder="ex: Arabe, Russe..."
placeholder={t('languages.customPlaceholder')}
className="w-full bg-card/60 border border-border rounded-xl px-4 py-2.5 text-[11px] outline-none focus:border-memento-blue transition-all text-foreground"
/>
</div>
@@ -951,8 +964,8 @@ export function ContextualAIChat({
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.style')}</span>
<select value={slideStyle} onChange={e => setSlideStyle(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-memento-blue/10 transition-all cursor-pointer text-foreground">
<option value="professional">{t('ai.generate.styleProfessional')}</option>
<option value="creative">Creative</option>
<option value="brutalist">Brutalist</option>
<option value="creative">{t('ai.generate.styleCreative')}</option>
<option value="brutalist">{t('ai.generate.styleBrutalist')}</option>
</select>
</div>
</div>
@@ -968,14 +981,14 @@ export function ContextualAIChat({
>
<div className="flex items-center justify-between">
<span className="text-[9px] font-bold text-memento-blue uppercase tracking-widest flex items-center gap-1.5">
<Check size={12} /> Présentation prête
<Check size={12} /> {t('ai.presentationReadyBadge')}
</span>
<a
href={`/lab?id=${generateResult.canvasId}`}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 bg-card/60 rounded-lg text-foreground/50 hover:text-foreground hover:bg-card transition-colors"
title="Voir dans L'Atelier"
title={t('ai.openInLabTitle')}
>
<ExternalLink size={12} />
</a>
@@ -1001,13 +1014,13 @@ export function ContextualAIChat({
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch {
mToast.error('Échec du téléchargement')
mToast.error(t('ai.downloadFailedToast'))
}
}}
className="flex items-center justify-center gap-2 w-full py-2.5 bg-memento-blue text-white rounded-lg text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity shadow-sm"
>
<Download size={13} />
Télécharger .pptx
{t('ai.pptxDownloadButton')}
</button>
</motion.div>
)}

View File

@@ -0,0 +1,211 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useLanguage } from '@/lib/i18n'
import { X, Send, FileText, Sparkles, Loader2, User, Plus, Square } from 'lucide-react'
interface Attachment {
id: string
fileName: string
}
interface DocumentQAOverlayProps {
attachment: Attachment
noteId: string
noteContent?: string
onClose: () => void
onApplyToNote?: (content: string) => void
}
function getMessageContent(msg: any): string {
if (typeof msg.content === 'string') return msg.content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text' && typeof p.text === 'string')
.map((p: any) => p.text)
.join('')
}
return ''
}
export function DocumentQAOverlay({ attachment, noteId, noteContent, onClose, onApplyToNote }: DocumentQAOverlayProps) {
const { t } = useLanguage()
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [input, setInput] = useState('')
const pdfUrl = `/api/notes/${noteId}/attachments/${attachment.id}?download=true`
const transport = useRef(new DefaultChatTransport({
api: '/api/chat',
body: {
noteId,
noteContext: {
title: attachment.fileName,
content: noteContent || '',
tone: 'professional',
},
webSearch: false,
},
})).current
const { messages, sendMessage, status, stop } = useChat({ transport })
const isLoading = status === 'submitted' || status === 'streaming'
const lastAssistantContent = [...messages].reverse().find(m => m.role === 'assistant')
const lastAssistantText = lastAssistantContent ? getMessageContent(lastAssistantContent) : ''
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [onClose])
const handleSend = async () => {
const text = input.trim()
if (!text || isLoading) return
setInput('')
try {
await sendMessage({ text })
} catch {
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-6xl h-[88vh] bg-background border border-border rounded-2xl shadow-2xl flex overflow-hidden">
{/* Left: PDF Preview */}
<div className="flex-1 flex flex-col border-r border-border">
<div className="p-4 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 text-primary rounded-lg">
<FileText size={18} />
</div>
<div className="min-w-0">
<h3 className="text-sm font-semibold truncate max-w-[300px]">{attachment.fileName}</h3>
<p className="text-[9px] uppercase font-bold tracking-widest text-muted-foreground">
PDF
</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-muted rounded-full text-muted-foreground transition-colors">
<X size={18} />
</button>
</div>
<div className="flex-1 bg-muted/30 overflow-hidden">
<iframe
src={pdfUrl}
className="w-full h-full border-0"
title={attachment.fileName}
/>
</div>
</div>
{/* Right: Chat */}
<div className="w-[400px] flex flex-col">
<div className="p-4 border-b border-border flex items-center gap-2">
<Sparkles size={16} className="text-primary" />
<h4 className="text-[11px] font-bold uppercase tracking-widest">
{t('attachments.docExpert') || 'Document Expert'}
</h4>
</div>
<div className="flex-1 p-4 overflow-y-auto space-y-3">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles size={24} className="text-primary/60" />
</div>
<p className="text-xs text-muted-foreground max-w-[220px] leading-relaxed">
{t('attachments.docQaWelcome') || `Posez une question sur "${attachment.fileName}".`}
</p>
</div>
)}
{messages.map((msg, i) => {
const content = getMessageContent(msg)
if (!content) return null
return (
<div key={msg.id || i} className={`flex gap-2.5 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles size={10} className="text-primary" />
</div>
)}
<div
className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 border border-border'
}`}
>
{content}
</div>
{msg.role === 'user' && (
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
<User size={10} className="text-muted-foreground" />
</div>
)}
</div>
)
})}
{isLoading && !lastAssistantText && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
<span className="text-xs">{t('attachments.thinking') || 'Thinking...'}</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
{lastAssistantText && onApplyToNote && (
<div className="px-4 py-2 border-t border-border">
<button
onClick={() => onApplyToNote(lastAssistantText)}
className="w-full flex items-center justify-center gap-2 py-2 text-[11px] font-bold uppercase tracking-widest text-primary hover:bg-primary/10 rounded-lg transition-colors"
>
<Plus size={14} />
{t('attachments.addToNote') || 'Ajouter à la note'}
</button>
</div>
)}
<div className="p-3 border-t border-border">
<div className="relative">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('attachments.askPlaceholder') || 'Ask about this document...'}
className="w-full bg-muted/50 border border-border rounded-xl p-3 pr-11 text-sm outline-none focus:border-primary transition-all resize-none"
rows={2}
disabled={isLoading}
/>
<button
onClick={isLoading ? stop : handleSend}
disabled={!isLoading && !input.trim()}
className="absolute right-2 bottom-2 p-2 bg-primary text-primary-foreground rounded-lg shadow-sm disabled:opacity-50 transition-all"
>
{isLoading ? <Square size={14} /> : <Send size={14} />}
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -111,7 +111,7 @@ export function HierarchicalNotebookSelector({
if (!searchQuery) setIsOpen(false)
}}
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold dark:bg-blueprint/10' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
${isSelected ? 'bg-memento-blue/15 text-memento-blue font-bold dark:bg-memento-blue/20' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
>
<div className="w-4 flex items-center justify-center">
{hasChildren ? (
@@ -124,7 +124,7 @@ export function HierarchicalNotebookSelector({
) : null}
</div>
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20 dark:bg-blueprint/20' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}>
<div className={`p-1 rounded ${isSelected ? 'bg-memento-blue/25 dark:bg-memento-blue/30' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}>
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
</div>
@@ -157,9 +157,9 @@ export function HierarchicalNotebookSelector({
<div
ref={triggerRef}
onClick={() => setIsOpen(prev => !prev)}
className={`w-full bg-card dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`}
className={`w-full bg-card dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-memento-blue/10 focus:border-memento-blue/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`}
>
<Folder size={size === 'sm' ? 14 : 16} className="text-blueprint/60 shrink-0" />
<Folder size={size === 'sm' ? 14 : 16} className="text-memento-blue/70 shrink-0" />
<div className="flex-1 flex items-center gap-1 min-w-0">
{path.length > 0 ? (
<div className="flex items-center gap-1.5 truncate">
@@ -201,7 +201,7 @@ export function HierarchicalNotebookSelector({
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={placeholder}
className="w-full bg-card border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-blueprint transition-colors"
className="w-full bg-card border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-memento-blue transition-colors"
/>
</div>
</div>
@@ -216,7 +216,7 @@ export function HierarchicalNotebookSelector({
</span>
<button
onClick={() => setIsOpen(false)}
className="text-[10px] font-bold text-blueprint hover:underline"
className="text-[10px] font-bold text-memento-blue hover:underline"
>
Close
</button>

View File

@@ -488,7 +488,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
))}
</div>
)}
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pr-12">
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pe-12">
{currentNotebook
? currentNotebook.name
: searchParams.get('shared') === '1'
@@ -600,10 +600,10 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
<button
onClick={() => setOrganizeNotebookOpen(true)}
className="flex items-center gap-2 text-[13px] text-blueprint font-medium hover:opacity-70 transition-opacity"
title="Organiser ce carnet avec l'IA"
title={t('notebook.organizeNotebookWithAITooltip')}
>
<Sparkles size={16} />
<span>Organiser</span>
<span>{t('batch.organize')}</span>
</button>
)}
</div>
@@ -616,7 +616,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
"flex items-center gap-2 text-[13px] font-medium transition-opacity",
initialSettings.aiAssistantEnabled ? "text-foreground hover:opacity-70" : "text-muted-foreground opacity-50 cursor-not-allowed"
)}
title={initialSettings.aiAssistantEnabled ? t('notebook.summary') : "Activez l'Assistant IA dans les paramètres pour résumer"}
title={initialSettings.aiAssistantEnabled ? t('notebook.summary') : t('notebook.assistantRequiredForSummarize')}
>
<FileText size={16} />
<span>{t('notebook.summary') || 'Summarize'}</span>
@@ -701,7 +701,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
{selectedTagIds.length > 0 && (
<button
onClick={() => setSelectedTagIds([])}
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-red-500 hover:underline ml-auto"
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-red-500 hover:underline ms-auto"
>
{t('labels.clearAll') || 'Clear all'}
</button>

View File

@@ -37,12 +37,12 @@ export function LabelBadge({
isSelected
? 'bg-foreground text-background border-foreground shadow-sm'
: isAI
? 'bg-[#75B2D6]/10 border-[#75B2D6]/25 text-[#75B2D6]'
? 'bg-[#A47148]/10 border-[#A47148]/25 text-[#A47148]'
: 'bg-[#8D8D8D]/10 border-[#8D8D8D]/25 text-[#8D8D8D]',
)}
>
{isAI && (
<Sparkles size={8} className="text-[#75B2D6]/70" />
<Sparkles size={8} className="text-[#A47148]/70" />
)}
<span className="truncate">{label}</span>
{onRemove && (
@@ -66,8 +66,8 @@ export function LabelBadge({
)}
{isAI && !isSelected && (
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#75B2D6] opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#75B2D6]" />
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#A47148] opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#A47148]" />
</span>
)}
</button>

View File

@@ -46,6 +46,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [demoMode, setDemoMode] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const dismissedPermanently = useRef(false)
// Fetch insight on mount
useEffect(() => {
@@ -63,8 +64,6 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
if (data.insight) {
setInsight(data.insight)
// If we got an insight, check if user is in demo mode
setDemoMode(true)
}
} catch (error) {
console.error('[MemoryEcho] Failed to fetch insight:', error)
@@ -73,28 +72,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
}
}
// Start polling in demo mode after first dismiss
useEffect(() => {
if (isDismissed && demoMode && !pollingRef.current) {
pollingRef.current = setInterval(async () => {
try {
const res = await fetch('/api/ai/echo')
const data = await res.json()
if (data.insight) {
setInsight(data.insight)
setIsDismissed(false)
}
} catch {
// silent
}
}, 15000) // Poll every 15s
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
}
if (dismissedPermanently.current) return
}, [isDismissed, demoMode])
const handleView = async () => {
@@ -158,6 +137,11 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
const handleDismiss = () => {
setIsDismissed(true)
dismissedPermanently.current = true
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
}
if (isLoading || !insight) {

View File

@@ -0,0 +1,247 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useLanguage } from '@/lib/i18n'
import { FileText, Loader2, MessageSquare, Trash2, AlertCircle, Plus } from 'lucide-react'
import { toast } from 'sonner'
interface Attachment {
id: string
fileName: string
fileSize: number
mimeType: string
status: 'pending' | 'processing' | 'ready' | 'failed'
pageCount: number | null
error: string | null
createdAt: string
}
interface NoteAttachmentsProps {
noteId: string
onOpenDocQA: (attachment: Attachment) => void
onCountChange?: (count: number) => void
triggerUpload?: number
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function NoteAttachments({ noteId, onOpenDocQA, onCountChange, triggerUpload }: NoteAttachmentsProps) {
const { t } = useLanguage()
const [attachments, setAttachments] = useState<Attachment[]>([])
const [uploading, setUploading] = useState(false)
const [loading, setLoading] = useState(true)
const fileInputRef = useRef<HTMLInputElement>(null)
const sectionRef = useRef<HTMLDivElement>(null)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fetchAttachments = useCallback(async () => {
try {
const res = await fetch(`/api/notes/${noteId}/attachments`)
if (res.ok) {
const data = await res.json()
const list = data.data || []
setAttachments(list)
onCountChange?.(list.length)
return list
}
} catch {}
return []
}, [noteId])
useEffect(() => {
fetchAttachments().finally(() => setLoading(false))
}, [fetchAttachments])
useEffect(() => {
onCountChange?.(attachments.length)
}, [attachments.length, onCountChange])
useEffect(() => {
const hasPending = attachments.some(a => a.status === 'pending' || a.status === 'processing')
if (hasPending) {
pollingRef.current = setInterval(fetchAttachments, 3000)
} else if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, [attachments, fetchAttachments])
useEffect(() => {
if (triggerUpload && triggerUpload > 0) {
if (attachments.length > 0) {
sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
fileInputRef.current?.click()
}
}, [triggerUpload, attachments.length])
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (file.type !== 'application/pdf') {
toast.error(t('attachments.onlyPdf') || 'Only PDF files are supported')
return
}
if (file.size > 20 * 1024 * 1024) {
toast.error(t('attachments.maxSize') || 'File too large (max 20MB)')
return
}
setUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`/api/notes/${noteId}/attachments`, {
method: 'POST',
body: formData,
})
if (res.ok) {
const data = await res.json()
setAttachments(prev => [data.data, ...prev])
toast.success(t('attachments.uploaded') || 'File uploaded — analyzing...')
await fetchAttachments()
setTimeout(() => {
sectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 200)
} else {
const err = await res.json()
toast.error(err.error || t('attachments.uploadFailed') || 'Upload failed')
}
} catch {
toast.error(t('attachments.uploadError') || 'Upload error')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const handleDelete = async (attachmentId: string) => {
try {
const res = await fetch(`/api/notes/${noteId}/attachments/${attachmentId}`, { method: 'DELETE' })
if (res.ok) {
setAttachments(prev => prev.filter(a => a.id !== attachmentId))
toast.success(t('attachments.deleted') || 'Attachment removed')
}
} catch {}
}
if (loading) return null
return (
<>
<input ref={fileInputRef} type="file" accept=".pdf" className="hidden" onChange={handleUpload} />
{attachments.length > 0 && (
<div ref={sectionRef} className="pt-8">
<div className="flex items-center justify-between mb-4">
<h4 className="text-[11px] uppercase font-bold tracking-[.2em] text-muted-foreground">
{t('attachments.title') || 'Documents'}
</h4>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
<Plus size={12} />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{attachments.map(att => (
<div
key={att.id}
className="relative group border border-border rounded-2xl bg-white dark:bg-white/[0.03] overflow-hidden transition-all hover:border-foreground/15"
>
<button
onClick={() => handleDelete(att.id)}
className="absolute top-2 right-2 p-1 rounded-lg text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-all z-10"
title={t('attachments.remove') || 'Remove'}
>
<Trash2 size={12} />
</button>
{(att.status === 'pending' || att.status === 'processing') && (
<div className="p-5">
<div className="flex items-center gap-3 mb-3">
<div className="p-2.5 bg-primary/10 text-primary rounded-xl">
<FileText size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium truncate">{att.fileName}</p>
<p className="text-[10px] text-muted-foreground">{formatFileSize(att.fileSize)}</p>
</div>
</div>
<div className="flex items-center gap-2 px-1">
<Loader2 size={12} className="animate-spin text-primary" />
<span className="text-[11px] text-primary font-medium">
{t('attachments.analyzing') || 'Analyzing document...'}
</span>
</div>
</div>
)}
{att.status === 'ready' && (
<button
onClick={() => onOpenDocQA(att)}
className="w-full text-left p-5"
>
<div className="flex items-center gap-3 mb-4">
<div className="p-2.5 bg-primary/10 text-primary rounded-xl">
<FileText size={20} />
</div>
<div className="min-w-0 flex-1 pr-4">
<p className="text-xs font-medium truncate">{att.fileName}</p>
<p className="text-[10px] text-muted-foreground">
{formatFileSize(att.fileSize)}
{att.pageCount ? ` · ${att.pageCount} pages` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2 px-1">
<MessageSquare size={12} className="text-primary" />
<span className="text-[11px] text-primary font-medium">
{t('attachments.askQuestions') || 'Ask questions about this document'}
</span>
</div>
</button>
)}
{att.status === 'failed' && (
<div className="p-5">
<div className="flex items-center gap-3 mb-3">
<div className="p-2.5 bg-destructive/10 text-destructive rounded-xl">
<FileText size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium truncate">{att.fileName}</p>
<p className="text-[10px] text-destructive">
{att.error || (t('attachments.processingFailed') || 'Processing failed')}
</p>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{uploading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-4">
<Loader2 size={14} className="animate-spin" />
<span>{t('attachments.uploading') || 'Uploading...'}</span>
</div>
)}
</>
)
}

View File

@@ -482,7 +482,7 @@ export const NoteCard = memo(function NoteCard({
>
{/* Drag Handle - Only visible on mobile/touch devices */}
<div
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
className="muuri-drag-handle absolute top-2 start-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
title={t('notes.dragToReorder') || 'Drag to reorder'}
>
@@ -490,7 +490,7 @@ export const NoteCard = memo(function NoteCard({
</div>
{/* Move to Notebook Dropdown Menu — hidden in trash */}
{!isTrashView && <div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
{!isTrashView && <div onClick={(e) => e.stopPropagation()} className="absolute top-2 end-2 z-20">
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
<DropdownMenuTrigger asChild>
<Button
@@ -507,7 +507,7 @@ export const NoteCard = memo(function NoteCard({
{t('notebookSuggestion.moveToNotebook')}
</div>
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
<StickyNote className="h-4 w-4 mr-2" />
<StickyNote className="h-4 w-4 me-2" />
{t('notebookSuggestion.generalNotes')}
</DropdownMenuItem>
{notebooks.filter(nb => !nb.parentId && !nb.trashedAt).map((notebook: any) => {
@@ -523,11 +523,11 @@ export const NoteCard = memo(function NoteCard({
<DropdownMenuSubTrigger className="gap-2">
<NotebookIcon className="h-4 w-4" />
{notebook.name}
<ChevronRight className="h-3 w-3 ml-auto opacity-50" />
<ChevronRight className="h-3 w-3 ms-auto opacity-50 rtl:rotate-180" />
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => handleMoveToNotebook(notebook.id)}>
<NotebookIcon className="h-4 w-4 mr-2" />
<NotebookIcon className="h-4 w-4 me-2" />
{notebook.name}
</DropdownMenuItem>
{descendants.map((child: any) => {
@@ -544,8 +544,8 @@ export const NoteCard = memo(function NoteCard({
})()
return (
<DropdownMenuItem key={child.id} onClick={() => handleMoveToNotebook(child.id)}>
<NotebookIcon className="h-4 w-4 mr-2" />
<span className="ml-{depth * 2}">{child.name}</span>
<NotebookIcon className="h-4 w-4 me-2" />
<span style={{ marginInlineStart: `${depth * 0.5}rem` }}>{child.name}</span>
</DropdownMenuItem>
)
})}
@@ -555,14 +555,14 @@ export const NoteCard = memo(function NoteCard({
}
return (
<DropdownMenuItem key={notebook.id} onClick={() => handleMoveToNotebook(notebook.id)}>
<NotebookIcon className="h-4 w-4 mr-2" />
<NotebookIcon className="h-4 w-4 me-2" />
{notebook.name}
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-muted-foreground" onSelect={() => onCreateSubNotebook?.()}>
<Plus className="h-4 w-4 mr-2" />
<Plus className="h-4 w-4 me-2" />
{t('notebook.createSubNotebook') || 'Nouveau sous-carnet…'}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -575,7 +575,7 @@ export const NoteCard = memo(function NoteCard({
size="sm"
data-testid="pin-button"
className={cn(
"absolute top-2 right-12 z-20 h-8 w-8 p-0 rounded-md transition-opacity",
"absolute top-2 end-12 z-20 h-8 w-8 p-0 rounded-md transition-opacity",
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => {
@@ -593,7 +593,7 @@ export const NoteCard = memo(function NoteCard({
{/* Reminder Icon - Move slightly if pin button is there */}
{note.reminder && new Date(note.reminder) > new Date() && (
<Bell
className="absolute top-3 right-10 h-4 w-4 text-primary"
className="absolute top-3 end-10 h-4 w-4 text-primary"
/>
)}
@@ -604,7 +604,7 @@ export const NoteCard = memo(function NoteCard({
{t('memoryEcho.fused')}
<button
onClick={handleRemoveFusedBadge}
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
className="ms-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
title={t('notes.remove') || 'Remove'}
>
<Trash2 className="h-2.5 w-2.5" />
@@ -614,7 +614,7 @@ export const NoteCard = memo(function NoteCard({
{/* Title */}
{note.title && (
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight flex items-center gap-2">
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pe-20 text-foreground leading-tight tracking-tight flex items-center gap-2">
{(() => {
const TypeIcon = NOTE_TYPE_ICONS[note.type] || AlignLeft
return <TypeIcon className="h-4 w-4 shrink-0 text-muted-foreground/50" />
@@ -653,7 +653,7 @@ export const NoteCard = memo(function NoteCard({
handleLeaveShare()
}}
>
<LogOut className="h-3 w-3 mr-1" />
<LogOut className="h-3 w-3 me-1" />
{t('notes.leaveShare')}
</Button>
</div>
@@ -727,7 +727,7 @@ export const NoteCard = memo(function NoteCard({
{owner && (
<div
className={cn(
"absolute bottom-2 left-2 z-20",
"absolute bottom-2 start-2 z-20",
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
getAvatarColor(owner.name || owner.email || 'Unknown')
)}
@@ -758,7 +758,7 @@ export const NoteCard = memo(function NoteCard({
noteId={note.id}
currentReminder={reminderDate}
onUpdateReminder={handleUpdateReminder}
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute bottom-0 start-0 end-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}
@@ -776,7 +776,7 @@ export const NoteCard = memo(function NoteCard({
)}
{/* Connections Badge - Bottom right (spec: amber, absolute) */}
<div className="absolute bottom-2 right-2 z-10">
<div className="absolute bottom-2 end-2 z-10">
<ConnectionsBadge
noteId={note.id}
onClick={() => {

View File

@@ -2,9 +2,11 @@
import { useState, useMemo } from 'react'
import { Note } from '@/lib/types'
import { format, formatDistanceToNow } from 'date-fns'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { faIR } from 'date-fns/locale/fa-IR'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { X, Info, Clock, Hash, Book, FileText, Calendar, Tag, ChevronRight, Trash2, RotateCcw, Loader2, Check, History as HistoryIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
@@ -24,7 +26,9 @@ interface NoteDocumentInfoPanelProps {
}
function getLocale(lang: string) {
return lang === 'fr' ? fr : enUS
if (lang === 'fr') return fr
if (lang === 'fa') return faIR
return enUS
}
function wordCount(text: string) {
@@ -35,13 +39,6 @@ function charCount(text: string) {
return text.replace(/<[^>]+>/g, '').length
}
const noteTypeLabel: Record<string, string> = {
richtext: 'Rich Text',
markdown: 'Markdown',
text: 'Texte',
checklist: 'Liste de tâches',
}
export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }: NoteDocumentInfoPanelProps) {
const { t, language } = useLanguage()
const { notebooks } = useNotebooks()
@@ -56,6 +53,16 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
const [isRestoring, setIsRestoring] = useState<string | null>(null)
const locale = getLocale(language)
const displayNoteType = useMemo(() => {
const map: Record<string, string> = {
richtext: t('notes.noteTypes.richtext'),
markdown: t('notes.noteTypes.markdown'),
text: t('notes.noteTypes.text'),
checklist: t('notes.noteTypes.checklist'),
}
return map[note.type] || note.type
}, [t, note.type])
useEffect(() => {
if (activeTab === 'versions' && historyEnabled) {
loadHistory()
@@ -75,7 +82,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
}
const handleDeleteVersion = async (entryId: string) => {
if (!confirm('Supprimer cette version ?')) return
if (!confirm(t('documentInfo.deleteVersionConfirm'))) return
setIsDeleting(entryId)
try {
await deleteNoteHistoryEntry(note.id, entryId)
@@ -131,7 +138,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
>
{tab === 'info' && <Info className="h-3 w-3" />}
{tab === 'versions' && <Clock className="h-3 w-3" />}
{tab === 'info' ? 'Info' : 'Versions'}
{tab === 'info' ? t('documentInfo.tabInfo') : t('documentInfo.tabVersions')}
</button>
))}
</div>
@@ -153,11 +160,11 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div className="grid grid-cols-2 border-b border-border/30">
<div className="flex flex-col items-center gap-1 py-6 border-r border-border/30">
<span className="text-4xl font-bold font-memento-serif tabular-nums tracking-tight">{words}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">mots</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">{t('documentInfo.wordsLabel')}</span>
</div>
<div className="flex flex-col items-center gap-1 py-6">
<span className="text-4xl font-bold font-memento-serif tabular-nums tracking-tight">{chars}</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">caractères</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-semibold">{t('documentInfo.charactersLabel')}</span>
</div>
</div>
@@ -166,7 +173,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div className="flex items-start gap-3 px-4 py-3">
<Book className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Carnet</p>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.notebookLabel')}</p>
<p className="text-sm font-medium">{notebook.name}</p>
</div>
</div>
@@ -175,8 +182,8 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div className="flex items-start gap-3 px-4 py-3">
<FileText className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Type</p>
<p className="text-sm font-medium">{noteTypeLabel[note.type] || note.type}</p>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.typeLabel')}</p>
<p className="text-sm font-medium">{displayNoteType}</p>
</div>
</div>
@@ -184,8 +191,8 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div className="flex items-start gap-3 px-4 py-3">
<Calendar className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Créée le</p>
<p className="text-sm font-medium">{format(createdAt, 'd MMM yyyy', { locale })}</p>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.createdLabel')}</p>
<p className="text-sm font-medium">{formatAbsoluteDateLocalized(createdAt, language, 'd MMM yyyy', locale)}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{formatDistanceToNow(createdAt, { addSuffix: true, locale })}
</p>
@@ -197,8 +204,8 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div className="flex items-start gap-3 px-4 py-3">
<Clock className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">Modifiée</p>
<p className="text-sm font-medium">{format(updatedAt, 'd MMM yyyy · HH:mm', { locale })}</p>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.modifiedLabel')}</p>
<p className="text-sm font-medium">{formatAbsoluteDateLocalized(updatedAt, language, 'd MMM yyyy · HH:mm', locale)}</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
{formatDistanceToNow(updatedAt, { addSuffix: true, locale })}
</p>
@@ -210,7 +217,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div className="flex items-start gap-3 px-4 py-3">
<Tag className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-1.5">Labels</p>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-1.5">{t('documentInfo.labelsSection')}</p>
<div className="flex flex-wrap gap-1">
{(note.labels ?? []).map(label => (
<LabelBadge key={label} label={label} />
@@ -223,7 +230,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div className="flex items-start gap-3 px-4 py-3">
<Hash className="h-3.5 w-3.5 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">ID</p>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground mb-0.5">{t('documentInfo.idLabel')}</p>
<p className="text-[11px] text-muted-foreground font-mono truncate">{note.id}</p>
</div>
</div>
@@ -237,7 +244,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
{!historyEnabled ? (
<div className="text-center py-6 space-y-3">
<Clock className="h-8 w-8 text-muted-foreground/30 mx-auto" />
<p className="text-sm text-muted-foreground">L'historique n'est pas activé pour cette note.</p>
<p className="text-sm text-muted-foreground">{t('documentInfo.historyDisabled')}</p>
<button
className="text-xs px-4 py-2 rounded-lg bg-foreground text-background font-medium hover:opacity-80 transition-opacity"
onClick={async () => {
@@ -246,12 +253,12 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
setShowHistory(true)
}}
>
Activer l'historique
{t('documentInfo.enableHistory')}
</button>
</div>
) : (
<div className="space-y-4">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">Versions sauvegardées</p>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">{t('documentInfo.savedVersions')}</p>
{/* Save version button */}
<button
@@ -278,11 +285,11 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
}}
>
{isSavingVersion ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" />Sauvegarde…</>
<><Loader2 className="h-3.5 w-3.5 animate-spin" />{t('documentInfo.savingEllipsis')}</>
) : versionSaved ? (
<><Check className="h-3.5 w-3.5" /> Version sauvegardée !</>
<><Check className="h-3.5 w-3.5" /> {t('documentInfo.versionSaved')}</>
) : (
<>Sauvegarder cette version</>
<>{t('documentInfo.saveThisVersion')}</>
)}
</button>
@@ -292,15 +299,15 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
{isLoadingHistory && historyEntries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 opacity-40">
<Loader2 className="h-6 w-6 animate-spin mb-2" />
<p className="text-[10px] uppercase tracking-widest">Chargement...</p>
<p className="text-[10px] uppercase tracking-widest">{t('documentInfo.loading')}</p>
</div>
) : historyEntries.length === 0 ? (
<div className="text-center py-8 opacity-40 border border-dashed rounded-xl">
<Clock className="h-6 w-6 mx-auto mb-2" />
<p className="text-[10px] uppercase tracking-widest">Aucune version</p>
<p className="text-[10px] uppercase tracking-widest">{t('documentInfo.noVersion')}</p>
</div>
) : (
<div className="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-px before:bg-border/40">
<div className="relative ps-6 space-y-6 before:absolute before:start-[11px] before:top-2 before:bottom-2 before:w-px before:bg-border/40">
{historyEntries.map((entry, idx) => {
const colors = ['#E2E8F0', '#ACB995', '#E9ECEF']
const dotColor = colors[idx % colors.length]
@@ -310,7 +317,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div key={entry.id} className="relative group">
{/* Dot */}
<div
className="absolute -left-[19px] top-1.5 h-3 w-3 rounded-full border-2 border-background z-10 shadow-sm"
className="absolute -start-[19px] top-1.5 h-3 w-3 rounded-full border-2 border-background z-10 shadow-sm"
style={{ backgroundColor: dotColor }}
/>
@@ -319,7 +326,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<div className="flex items-center gap-2">
<span className="text-xs font-bold font-mono">v{entry.version}</span>
{isLatest && (
<span className="text-[9px] px-1.5 py-0.5 rounded-md bg-primary/10 text-primary font-bold uppercase tracking-widest">Latest</span>
<span className="text-[9px] px-1.5 py-0.5 rounded-md bg-primary/10 text-primary font-bold uppercase tracking-widest">{t('documentInfo.latestBadge')}</span>
)}
</div>
@@ -328,7 +335,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
onClick={() => handleRestoreVersion(entry.id)}
disabled={!!isRestoring || !!isDeleting}
className="p-1.5 rounded-lg hover:bg-primary/10 text-muted-foreground hover:text-primary transition-colors"
title="Restaurer"
title={t('documentInfo.restoreTooltip')}
>
{isRestoring === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />}
</button>
@@ -336,7 +343,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
onClick={() => handleDeleteVersion(entry.id)}
disabled={!!isRestoring || !!isDeleting}
className="p-1.5 rounded-lg hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors"
title="Supprimer"
title={t('documentInfo.deleteTooltip')}
>
{isDeleting === entry.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</button>
@@ -344,7 +351,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
</div>
<p className="text-[10px] text-muted-foreground font-medium">
{format(new Date(entry.createdAt), 'd MMM · HH:mm', { locale })}
{formatAbsoluteDateLocalized(new Date(entry.createdAt), language, 'd MMM · HH:mm', locale)}
<span className="mx-1.5 opacity-30">·</span>
{formatDistanceToNow(new Date(entry.createdAt), { addSuffix: true, locale })}
</p>
@@ -357,7 +364,7 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
{/* Button to open the full modal (optional, but good to keep if user wants diff) */}
<button
className="w-full flex items-center justify-between p-3 rounded-xl border border-border/40 hover:bg-muted/50 transition-colors text-left group mt-4"
className="w-full flex items-center justify-between p-3 rounded-xl border border-border/40 hover:bg-muted/50 transition-colors text-start group mt-4"
onClick={() => setShowHistory(true)}
>
<div className="flex items-center gap-3">
@@ -365,11 +372,11 @@ export function NoteDocumentInfoPanel({ note, content, onClose, onNoteRestored }
<HistoryIcon className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-bold uppercase tracking-wider">Mode Comparaison</p>
<p className="text-[10px] text-muted-foreground">Comparer les versions côte à côte</p>
<p className="text-xs font-bold uppercase tracking-wider">{t('documentInfo.comparisonMode')}</p>
<p className="text-[10px] text-muted-foreground">{t('documentInfo.comparisonSubtitle')}</p>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
<ChevronRight className="h-4 w-4 text-muted-foreground rtl:scale-x-[-1]" />
</button>
</div>
)}

View File

@@ -664,10 +664,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
setIsDirty(false)
toast.success('Note sauvegardée !')
toast.success(t('notes.saved') || 'Saved')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
toast.error('Erreur lors de la sauvegarde.')
toast.error(t('notes.saveFailed') || 'Save failed')
} finally {
setIsSaving(false)
}

View File

@@ -10,19 +10,30 @@ import { FusionModal } from '@/components/fusion-modal'
import { ReminderDialog } from '@/components/reminder-dialog'
import { ContextualAIChat } from '@/components/contextual-ai-chat'
import { NoteDocumentInfoPanel } from '@/components/note-document-info-panel'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { ChevronRight } from 'lucide-react'
import { toast } from 'sonner'
import { Note } from '@/lib/types'
import { GhostTags } from '@/components/ghost-tags'
import { LabelBadge } from '@/components/label-badge'
import { NoteAttachments } from '@/components/note-attachments'
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
import { useLanguage } from '@/lib/i18n'
import { useState } from 'react'
interface NoteEditorFullPageProps {
onClose: () => void
}
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const { t, language } = useLanguage()
const dateLocale = language === 'fr' ? fr : enUS
const { state, actions, note, readOnly, notebooks, fileInputRef, globalLabels } = useNoteEditorContext()
const [docQAAttachment, setDocQAAttachment] = useState<{ id: string; fileName: string } | null>(null)
const [attachmentsCount, setAttachmentsCount] = useState(0)
const [uploadTrigger, setUploadTrigger] = useState(0)
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
@@ -40,7 +51,7 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
<NoteEditorToolbar mode="fullPage" onClose={onClose} onToggleAttachments={() => setUploadTrigger(v => v + 1)} attachmentsCount={attachmentsCount} />
{/* BODY — max-w-4xl, responsive px, py-16 */}
<div className="max-w-4xl mx-auto w-full px-6 sm:px-12 py-16 space-y-12 min-w-0">
@@ -52,7 +63,7 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
{notebookName && <span style={{ color: 'var(--color-ink)' }}>{notebookName}</span>}
{notebookName && <ChevronRight size={10} style={{ color: 'var(--color-concrete)' }} />}
<span suppressHydrationWarning style={{ color: 'var(--color-concrete)' }}>
{format(new Date(note.contentUpdatedAt), 'MMM d, yyyy')}
{formatAbsoluteDateLocalized(new Date(note.contentUpdatedAt), language, 'MMM d, yyyy', dateLocale)}
</span>
</div>
@@ -94,8 +105,15 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
)}
{/* Content area — max-w-3xl for wider reading column */}
<div className="max-w-3xl mx-auto w-full pb-32">
<div className="max-w-3xl mx-auto w-full space-y-8 pb-32">
<NoteContentArea />
<NoteAttachments
noteId={note.id}
onOpenDocQA={(att) => setDocQAAttachment(att)}
onCountChange={setAttachmentsCount}
triggerUpload={uploadTrigger}
/>
</div>
</div>
</div>
@@ -124,7 +142,7 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
return
}
actions.setIsProcessingAI(true)
@@ -139,14 +157,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
toast.success('Titre généré !')
toast.success(t('ai.titleApplied'))
} else {
toast.error('Impossible de générer un titre.')
toast.error(t('ai.titleGenerationFailed'))
}
} else {
toast.error('Erreur lors de la génération du titre.')
toast.error(t('ai.titleGenerationError'))
}
} catch { toast.error('Erreur réseau.') } finally { actions.setIsProcessingAI(false) }
} catch { toast.error(t('ai.networkErrorShort')) } finally { actions.setIsProcessingAI(false) }
}}
/>
</div>
@@ -166,6 +184,20 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
</div>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={actions.handleImageUpload} />
{docQAAttachment && (
<DocumentQAOverlay
attachment={docQAAttachment}
noteId={note.id}
noteContent={state.content}
onClose={() => setDocQAAttachment(null)}
onApplyToNote={(content) => {
actions.setPreviousContentForCopilot(state.content)
actions.setContent(state.content + '\n\n' + content)
}}
/>
)}
<ReminderDialog
open={state.showReminderDialog}
onOpenChange={actions.setShowReminderDialog}

View File

@@ -18,7 +18,7 @@ import { Badge } from '@/components/ui/badge'
import {
X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles,
Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal,
Trash2, LogOut, Wand2, Share2
Trash2, LogOut, Wand2, Share2, Wind, Paperclip
} from 'lucide-react'
import { NoteShareDialog } from './note-share-dialog'
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
@@ -32,9 +32,11 @@ import { format } from 'date-fns'
interface NoteEditorToolbarProps {
mode: 'fullPage' | 'dialog'
onClose: () => void
onToggleAttachments?: () => void
attachmentsCount?: number
}
export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) {
const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef } = useNoteEditorContext()
const { t } = useLanguage()
const { refreshNotes } = useRefresh()
@@ -95,22 +97,22 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">Back to collection</span>
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
</button>
<div className="flex items-center gap-4">
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
{state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving</span></>
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
: state.isDirty
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>Modified</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>{t('notes.dirtyStatus')}</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span>{t('notes.savedStatus')}</span></>}
</span>
{state.isMarkdown && !readOnly && (
<button
title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'}
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
@@ -140,8 +142,8 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
)}
<button
title="AI Assistant"
aria-label="Ouvrir l'assistant IA"
title={t('ai.openAssistant')}
aria-label={t('ai.openAssistant')}
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
@@ -153,10 +155,41 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
<Sparkles size={16} />
</button>
<button
title={t('notes.brainstormThisIdea')}
aria-label={t('notes.brainstormThisIdeaAria')}
onClick={() => {
const title = note.title || ''
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
const seed = title ? `${title}. ${summary}` : summary
if (!seed.trim()) return
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
}}
className="p-1.5 rounded-full border border-orange-300 dark:border-orange-700 text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-all"
>
<Wind size={16} />
</button>
{!readOnly && onToggleAttachments && (
<button
title={t('notes.attachments') || 'Attachments'}
aria-label={t('notes.attachments') || 'Attachments'}
onClick={onToggleAttachments}
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Paperclip size={16} />
{(attachmentsCount ?? 0) > 0 && (
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
{attachmentsCount}
</span>
)}
</button>
)}
{!readOnly && (
<button
title={state.isDirty ? 'Enregistrer' : 'Aucune modification'}
aria-label={state.isDirty ? 'Enregistrer la note' : 'Aucune modification à enregistrer'}
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={actions.handleSaveInPlace}
disabled={state.isSaving || !state.isDirty}
className={cn(
@@ -172,8 +205,8 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
{!readOnly && (
<button
title="Partager la note"
aria-label="Partager la note"
title={t('notes.shareNoteTitle')}
aria-label={t('notes.shareNoteAria')}
onClick={() => setShareOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
@@ -184,7 +217,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="Menu des options" className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<button aria-label={t('notes.optionsMenuAria')} className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
@@ -194,14 +227,14 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
try {
await deleteNote(note.id)
refreshNotes(note.notebookId)
toast.success('Note supprimée.')
toast.success(t('notes.noteDeletedToast'))
onClose()
} catch { toast.error('Impossible de supprimer.') }
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
}}
className="text-red-600 dark:text-red-400 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer la note
<Trash2 className="h-4 w-4 me-2" />
{t('notes.deleteNoteConfirmItem')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -216,7 +249,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
)}
<button
aria-label="Informations du document"
aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
@@ -259,11 +292,15 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</Button>
)}
<Button variant="ghost" size="sm"
<Button
variant="ghost"
size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
onClick={() => actions.setAiOpen(!state.aiOpen)}
title={t('ai.aiNoteTitle')}
>
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">IA Note</span>
<span className="hidden sm:inline">{t('ai.aiNoteTitle')}</span>
</Button>
<DropdownMenu>
@@ -330,7 +367,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
onClick={async () => {
try {
await leaveSharedNote(note.id)
toast.success(t('notes.leftShare') || 'Share removed')
toast.success(t('notes.leftShare'))
refreshNotes(note.notebookId)
onClose()
} catch {

View File

@@ -6,6 +6,7 @@ import { createShareRequest, removeCollaborator, getNoteCollaborators } from '@/
import { toast } from 'sonner'
import { X, UserPlus, Users, Mail, Trash2, Loader2, Share2, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface Collaborator {
id: string
@@ -21,6 +22,7 @@ interface NoteShareDialogProps {
}
export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogProps) {
const { t } = useLanguage()
const [email, setEmail] = useState('')
const [permission, setPermission] = useState<'view' | 'edit'>('view')
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
@@ -60,13 +62,13 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
await createShareRequest(noteId, trimmed, permission)
setSent(true)
setEmail('')
toast.success(`Invitation envoyée à ${trimmed}`)
toast.success(t('collaboration.toastInviteSentTo', { email: trimmed }))
setTimeout(() => setSent(false), 2000)
loadCollaborators()
} catch (err: any) {
const msg = err?.message || 'Erreur lors du partage'
if (msg.includes('not found')) toast.error('Aucun compte trouvé avec cet email.')
else if (msg.includes('already shared')) toast.error('Cette note est déjà partagée avec cet utilisateur.')
const msg = err?.message || t('collaboration.toastSharingError')
if (msg.includes('not found')) toast.error(t('collaboration.toastEmailNotFound'))
else if (msg.includes('already shared')) toast.error(t('collaboration.toastAlreadySharedUser'))
else toast.error(msg)
} finally {
setSending(false)
@@ -78,9 +80,11 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
try {
await removeCollaborator(noteId, collaboratorId)
setCollaborators(prev => prev.filter(c => c.id !== collaboratorId))
toast.success(`Accès retiré à ${collaboratorEmail || "l'utilisateur"}`)
toast.success(t('collaboration.toastAccessRemoved', {
target: collaboratorEmail || t('collaboration.toastUserFallback'),
}))
} catch {
toast.error("Impossible de retirer l'accès.")
toast.error(t('collaboration.toastRemoveAccessFailed'))
} finally {
setRemovingId(null)
}
@@ -100,8 +104,8 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
{/* Header */}
<div className="px-6 pt-6 pb-4 border-b border-black/10 dark:border-white/10 flex items-start justify-between">
<div className="flex items-center gap-2">
<Share2 size={15} className="text-[#75B2D6]" />
<h2 className="text-sm font-bold text-foreground tracking-tight">Partager</h2>
<Share2 size={15} className="text-[#A47148]" />
<h2 className="text-sm font-bold text-foreground tracking-tight">{t('collaboration.shareCompactTitle')}</h2>
</div>
<button
onClick={onClose}
@@ -114,7 +118,7 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
{/* Invite form */}
<form onSubmit={handleInvite} className="px-6 py-5 space-y-3">
<label className="text-[9px] uppercase tracking-[0.25em] font-bold text-foreground/40">
Inviter par email
{t('collaboration.inviteByEmailLabel')}
</label>
<div className="flex gap-2">
<div className="relative flex-1">
@@ -126,7 +130,7 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
onChange={e => setEmail(e.target.value)}
required
autoFocus
className="w-full pl-9 pr-3 py-2.5 text-[13px] rounded-xl border border-black/15 dark:border-white/15 bg-transparent outline-none focus:ring-2 ring-[#75B2D6]/30 focus:border-[#75B2D6] transition-all placeholder:text-foreground/30"
className="w-full pl-9 pr-3 py-2.5 text-[13px] rounded-xl border border-black/15 dark:border-white/15 bg-transparent outline-none focus:ring-2 ring-[#A47148]/30 focus:border-[#A47148] transition-all placeholder:text-foreground/30"
/>
</div>
{/* Permission toggle */}
@@ -139,11 +143,11 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
className={cn(
'px-3 py-2 text-[10px] font-bold uppercase tracking-wide transition-colors',
permission === p
? 'bg-[#75B2D6] text-white'
? 'bg-[#A47148] text-white'
: 'text-foreground/50 hover:bg-black/5 dark:hover:bg-white/5'
)}
>
{p === 'view' ? 'Lire' : 'Éditer'}
{p === 'view' ? t('collaboration.accessReadCompact') : t('collaboration.accessEditCompact')}
</button>
))}
</div>
@@ -158,14 +162,14 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
? 'bg-black/5 dark:bg-white/5 text-foreground/30 cursor-not-allowed'
: sent
? 'bg-emerald-500 text-white'
: 'bg-[#75B2D6] text-white hover:opacity-90 shadow-sm shadow-[#75B2D6]/30'
: 'bg-[#A47148] text-white hover:opacity-90 shadow-sm shadow-[#A47148]/30'
)}
>
{sending
? <Loader2 size={13} className="animate-spin" />
: sent
? <><Check size={13} /> Invitation envoyée</>
: <><UserPlus size={13} /> Envoyer l&apos;invitation</>
? <><Check size={13} /> {t('collaboration.invitationSentBadge')}</>
: <><UserPlus size={13} /> {t('collaboration.sendInvitation')}</>
}
</button>
</form>
@@ -175,7 +179,7 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
<span className="text-[9px] uppercase tracking-[0.25em] font-bold text-foreground/30 flex items-center gap-1.5">
<Users size={10} /> Accès partagé
<Users size={10} /> {t('collaboration.sharedAccessLabel')}
</span>
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
</div>
@@ -186,7 +190,7 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
</div>
) : collaborators.length === 0 ? (
<p className="text-center text-[11px] text-foreground/30 py-4">
Aucun collaborateur pour l&apos;instant.
{t('collaboration.noCollaboratorsEmpty')}
</p>
) : (
<ul className="space-y-2">
@@ -199,14 +203,14 @@ export function NoteShareDialog({ noteId, noteTitle, onClose }: NoteShareDialogP
}
</div>
<div className="flex-1 min-w-0">
<p className="text-[12px] font-semibold text-foreground truncate">{c.name || 'Utilisateur'}</p>
<p className="text-[12px] font-semibold text-foreground truncate">{c.name || t('collaboration.userFallback')}</p>
<p className="text-[10px] text-foreground/40 truncate">{c.email}</p>
</div>
<button
onClick={() => handleRemove(c.id, c.email)}
disabled={removingId === c.id}
className="p-1.5 rounded-lg text-foreground/30 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
title="Retirer l'accès"
title={t('collaboration.removeAccessTitle')}
>
{removingId === c.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
</button>

View File

@@ -61,7 +61,7 @@ export function NoteTitleBlock() {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length
if (wordCount < 10) {
toast.error('Ajoutez au moins 10 mots avant de générer un titre.')
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
return
}
actions.setIsProcessingAI(true)
@@ -76,20 +76,20 @@ export function NoteTitleBlock() {
const s = data.suggestions?.[0]?.title ?? ''
if (s) {
actions.setTitle(s)
toast.success('Titre généré !')
toast.success(t('ai.titleApplied'))
} else {
toast.error('Impossible de générer un titre.')
toast.error(t('ai.titleGenerationFailed'))
}
} else {
toast.error('Erreur lors de la génération du titre.')
toast.error(t('ai.titleGenerationError'))
}
} catch (e) {
toast.error('Erreur réseau.')
toast.error(t('ai.networkErrorShort'))
} finally { actions.setIsProcessingAI(false) }
}}
disabled={state.isProcessingAI}
className="absolute right-0 top-2 opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity rounded-lg p-2 text-foreground/50 hover:bg-black/5"
title="Générer un titre automatique avec l'IA"
title={t('ai.generateTitlesTooltip')}
>
{state.isProcessingAI ? <Loader2 className="h-5 w-5 animate-spin" /> : <Sparkles className="h-5 w-5" />}
</button>

View File

@@ -1,9 +1,10 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { faIR } from 'date-fns/locale/fa-IR'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import * as Diff from 'diff'
import {
History, Loader2, RotateCcw, Trash2, GitBranchPlus, Check, GitCompare, X,
@@ -36,12 +37,14 @@ interface NoteHistoryModalProps {
type ViewMode = 'preview' | 'diff'
function getDateLocale(language: string) {
return language === 'fr' ? fr : enUS
if (language === 'fr') return fr
if (language === 'fa') return faIR
return enUS
}
function fmtDate(date: Date | string, language: string): string {
const d = typeof date === 'string' ? new Date(date) : date
return format(d, 'd MMM yyyy HH:mm', { locale: getDateLocale(language) })
return formatAbsoluteDateLocalized(d, language, 'd MMM yyyy HH:mm', getDateLocale(language))
}
function VersionPreview({ entry, language }: { entry: NoteHistoryEntry; language: string }) {
@@ -306,7 +309,7 @@ export function NoteHistoryModal({
)}
>
{/* ── Header ── */}
<div className="border-b border-border/60 px-5 py-3 pr-10">
<div className="border-b border-border/60 px-5 py-3 pe-10">
<DialogTitle className="flex items-center gap-2 text-sm">
<History className="h-4 w-4 text-primary" />
{t('notes.history') || 'Historique'}
@@ -329,7 +332,7 @@ export function NoteHistoryModal({
{t('notes.historyDisabledDesc') || "Activez l'historique pour enregistrer les versions."}
</p>
<Button onClick={handleEnable} disabled={isEnabling} size="lg" className="rounded-full px-8">
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEnabling && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t('notes.enableHistory') || "Activer l'historique"}
</Button>
</div>
@@ -346,7 +349,7 @@ export function NoteHistoryModal({
) : (
<div className="grid grid-cols-[210px_1fr]">
{/* ── Left: Version list ── */}
<div className="max-h-[65vh] overflow-y-auto border-r border-border/60 p-2 space-y-1">
<div className="max-h-[65vh] overflow-y-auto border-e border-border/60 p-2 space-y-1">
{entries.map((entry) => {
const isCurrent = entry.version === currentVersion?.version
const isSelected = viewMode === 'preview' && selectedId === entry.id
@@ -373,7 +376,7 @@ export function NoteHistoryModal({
: isSelected ? 'border-primary/40 bg-primary/8' : 'border-transparent hover:bg-muted/60'
)}
>
<div className="flex items-center gap-1.5 pr-10">
<div className="flex items-center gap-1.5 pe-10">
<span className="text-xs font-semibold text-foreground">v{entry.version}</span>
{isCurrent && (
<span className="rounded bg-primary/15 px-1.5 py-px text-[10px] font-medium text-primary whitespace-nowrap">
@@ -386,7 +389,7 @@ export function NoteHistoryModal({
</p>
<div className={cn(
'absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5',
'absolute end-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5',
isSelected || isDiffSel ? 'opacity-100' : 'opacity-0 group-hover/entry:opacity-100'
)}>
{viewMode === 'preview' && (

View File

@@ -561,7 +561,7 @@ export function NoteInlineEditor({
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}
title="IA Note">
title={t('ai.aiNoteTitle')}>
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />}
@@ -601,7 +601,7 @@ export function NoteInlineEditor({
<GitCommitHorizontal className="h-3.5 w-3.5" />
</Button>
)}
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
<span className="me-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> {t('notes.saving')}</>
) : isDirty ? (
@@ -624,7 +624,7 @@ export function NoteInlineEditor({
})
}}
>
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 mr-1.5" />
<span className="h-1.5 w-1.5 rounded-full bg-amber-400 me-1.5" />
{t('notes.saveNow') || 'Enregistrer'}
</Button>
) : (
@@ -664,8 +664,8 @@ export function NoteInlineEditor({
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleToggleArchive}>
{note.isArchived
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
? <><ArchiveRestore className="h-4 w-4 me-2" />{t('notes.unarchive')}</>
: <><Archive className="h-4 w-4 me-2" />{t('notes.archive')}</>}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem
@@ -677,7 +677,7 @@ export function NoteInlineEditor({
}
}}
>
<History className="h-4 w-4 mr-2" />
<History className="h-4 w-4 me-2" />
{note.historyEnabled
? (t('notes.history') || 'Historique')
: (t('notes.enableHistory') || "Activer l'historique")}
@@ -685,7 +685,7 @@ export function NoteInlineEditor({
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
<Trash2 className="h-4 w-4 me-2" />{t('notes.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -813,7 +813,7 @@ export function NoteInlineEditor({
</a>
</div>
<button type="button"
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
className="absolute end-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />

View File

@@ -25,6 +25,10 @@ import { deleteNote, toggleArchive, togglePin, updateNote } from '@/app/actions/
import { ReminderDialog } from '@/components/reminder-dialog'
import { useNotebooks } from '@/context/notebooks-context'
import { toast } from 'sonner'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
import { cn } from '@/lib/utils'
type NotesEditorialViewProps = {
notes: Note[]
@@ -33,13 +37,16 @@ type NotesEditorialViewProps = {
onOpenHistory?: (note: Note) => void
}
function formatNoteDate(date: Date | string): string {
function formatNoteDate(date: Date | string, language: string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).toUpperCase()
const locale = language === 'fr' ? fr : enUS
if (language === 'fa') {
return formatAbsoluteDateLocalized(d, language, 'd MMM yyyy', locale)
}
const month = d.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' })
const day = d.getUTCDate()
const year = d.getUTCFullYear()
return `${month.toUpperCase()} ${day}, ${year}`
}
function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
@@ -113,27 +120,27 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={e => { e.stopPropagation(); onOpen(note) }}>
<Pencil className="h-4 w-4 mr-2 text-foreground/50" />
<Pencil className="h-4 w-4 me-2 text-foreground/50" />
{t('notes.open') || 'Ouvrir'}
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePin}>
<Pin className="h-4 w-4 mr-2 text-foreground/50" />
<Pin className="h-4 w-4 me-2 text-foreground/50" />
{note.isPinned ? (t('notes.unpin') || 'Désépingler') : (t('notes.pin') || 'Épingler')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchive}>
<Archive className="h-4 w-4 mr-2 text-foreground/50" />
<Archive className="h-4 w-4 me-2 text-foreground/50" />
{note.isArchived ? (t('notes.unarchive') || 'Désarchiver') : (t('notes.archive') || 'Archiver')}
</DropdownMenuItem>
{onOpenHistory && (
<DropdownMenuItem onClick={e => { e.stopPropagation(); onOpenHistory(note) }}>
<History className="h-4 w-4 mr-2 text-foreground/50" />
<History className="h-4 w-4 me-2 text-foreground/50" />
{t('notes.history') || 'Historique'}
</DropdownMenuItem>
)}
{/* Rappel */}
<DropdownMenuItem onClick={e => { e.stopPropagation(); setShowReminder(true) }}>
<Bell className="h-4 w-4 mr-2 text-foreground/50" />
<Bell className="h-4 w-4 me-2 text-foreground/50" />
{note.reminder
? (t('reminder.changeReminder') || 'Modifier le rappel')
: (t('reminder.setReminder') || 'Définir un rappel')}
@@ -142,17 +149,17 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
{/* Déplacer vers un carnet */}
<DropdownMenuSub>
<DropdownMenuSubTrigger onClick={e => e.stopPropagation()}>
<FolderOpen className="h-4 w-4 mr-2 text-foreground/50" />
<FolderOpen className="h-4 w-4 me-2 text-foreground/50" />
{t('notebookSuggestion.moveToNotebook') || 'Déplacer vers…'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
<DropdownMenuItem onClick={e => { e.stopPropagation(); handleMoveToNotebook(null) }}>
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold mr-2 shrink-0">N</span>
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold me-2 shrink-0">N</span>
{t('notebookSuggestion.generalNotes') || 'Notes générales'}
</DropdownMenuItem>
{notebooks.map((nb: any) => (
<DropdownMenuItem key={nb.id} onClick={e => { e.stopPropagation(); handleMoveToNotebook(nb.id) }}>
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold mr-2 shrink-0">{nb.name.charAt(0).toUpperCase()}</span>
<span className="w-4 h-4 rounded-full bg-foreground text-background flex items-center justify-center text-[9px] font-semibold me-2 shrink-0">{nb.name.charAt(0).toUpperCase()}</span>
{nb.name}
</DropdownMenuItem>
))}
@@ -161,7 +168,7 @@ function EditorialNoteMenu({ note, onOpen, onOpenHistory }: {
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive focus:bg-destructive/10">
<Trash2 className="h-4 w-4 mr-2" />
<Trash2 className="h-4 w-4 me-2" />
{t('notes.delete') || 'Supprimer'}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -252,7 +259,7 @@ function EditorialThumbnail({
type="button"
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
className="absolute bottom-2 right-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10 opacity-0 group-hover/thumb:opacity-100 md:opacity-100 focus-visible:opacity-100"
className="absolute bottom-2 end-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10 opacity-0 group-hover/thumb:opacity-100 md:opacity-100 focus-visible:opacity-100"
onClick={handleGenerateSvg}
disabled={busy}
>
@@ -333,7 +340,7 @@ export function NotesEditorialView({
notebookName,
onOpenHistory,
}: NotesEditorialViewProps) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const { data: session } = useSession()
const { data: allLabels } = useLabelsQuery()
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
@@ -354,7 +361,8 @@ export function NotesEditorialView({
{notes.map((note: Note, index: number) => {
const title = getNoteDisplayTitle(note, t('notes.untitled') || 'Untitled')
const excerpt = getNotePlainExcerpt(note)
const dateStr = formatNoteDate(note.createdAt)
const dateStr = formatNoteDate(note.createdAt, language)
const editorialRtl = language === 'fa' || language === 'ar'
return (
<motion.article
@@ -365,14 +373,31 @@ export function NotesEditorialView({
className="space-y-4 group cursor-pointer relative pb-8"
onClick={() => onOpen(note)}
>
{/* Date / breadcrumb */}
<div className="note-date-badge">
{notebookName ? `${notebookName}${dateStr}` : dateStr}
{/* Date / breadcrumb — isolated bidi so Latin notebook name + Jalali date dont reorder wrongly */}
<div
className={cn('note-date-badge', editorialRtl && 'note-date-badge--locale-rtl')}
dir={editorialRtl ? 'rtl' : 'ltr'}
>
{notebookName ? (
<>
<bdi className={cn(editorialRtl && 'uppercase tracking-[0.2em]')}>{notebookName}</bdi>
<span className="mx-1.5 select-none text-muted-foreground/80" aria-hidden>
</span>
<bdi dir="rtl" lang={editorialRtl ? 'fa' : undefined}>
{dateStr}
</bdi>
</>
) : (
<bdi dir={editorialRtl ? 'rtl' : 'ltr'} lang={editorialRtl ? 'fa' : undefined}>
{dateStr}
</bdi>
)}
</div>
{/* Actions menu — absolutely positioned at top-right */}
<div
className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute top-0 end-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={e => e.stopPropagation()}
>
<EditorialNoteMenu note={note} onOpen={onOpen} onOpenHistory={onOpenHistory} />

View File

@@ -4,13 +4,14 @@ import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation } from 'lucide-react'
import { Bell, Check, X, Clock, AlertCircle, CheckCircle2, Circle, Share2, Bot, Trash2, Download, Pencil, Presentation, Wind } from 'lucide-react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { getPendingShareRequests, respondToShareRequest, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
import { getPendingBrainstormShares, respondToBrainstormShare } from '@/app/actions/brainstorm'
import { getUnreadNotifications, markNotificationRead, markAllNotificationsRead, type AppNotification } from '@/app/actions/notifications'
import { toast } from 'sonner'
import { useRefresh } from '@/lib/use-refresh'
@@ -60,23 +61,29 @@ export function NotificationPanel() {
const { t } = useLanguage()
const router = useRouter()
const [requests, setRequests] = useState<ShareRequest[]>([])
const [brainstormShares, setBrainstormShares] = useState<any[]>([])
const [reminders, setReminders] = useState<ReminderNote[]>([])
const [appNotifications, setAppNotifications] = useState<AppNotification[]>([])
const [isLoading, setIsLoading] = useState(false)
const [open, setOpen] = useState(false)
const loadData = useCallback(async () => {
setIsLoading(true)
try {
const [shareData, reminderData, notifData] = await Promise.all([
const [shareData, brainstormData, reminderData, notifData] = await Promise.all([
getPendingShareRequests(),
getPendingBrainstormShares(),
getNotesWithReminders(),
getUnreadNotifications(),
])
setRequests(shareData as any)
setBrainstormShares(brainstormData as any || [])
setReminders((reminderData as any) || [])
setAppNotifications(notifData || [])
} catch (error: any) {
console.error('Failed to load notifications:', error)
} finally {
setIsLoading(false)
}
}, [])
@@ -96,7 +103,7 @@ export function NotificationPanel() {
const overdueReminders = activeReminders.filter(r => new Date(r.reminder!) < now)
const upcomingReminders = activeReminders.filter(r => new Date(r.reminder!) >= now)
const pendingCount = requests.length + overdueReminders.length + appNotifications.length
const pendingCount = requests.length + brainstormShares.length + overdueReminders.length + appNotifications.length
const handleAccept = async (shareId: string) => {
try {
@@ -144,7 +151,29 @@ export function NotificationPanel() {
setAppNotifications([])
}
const hasContent = requests.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
const handleAcceptBrainstorm = async (shareId: string) => {
try {
await respondToBrainstormShare(shareId, 'accept')
setBrainstormShares(prev => prev.filter(s => s.id !== shareId))
toast.success(t('notification.accepted') || 'Accepted')
setOpen(false)
} catch (error: any) {
toast.error(error.message || t('general.error'))
}
}
const handleDeclineBrainstorm = async (shareId: string) => {
try {
await respondToBrainstormShare(shareId, 'decline')
setBrainstormShares(prev => prev.filter(s => s.id !== shareId))
toast.info(t('notification.declined') || 'Declined')
if (brainstormShares.length <= 1) setOpen(false)
} catch (error: any) {
toast.error(error.message || t('general.error'))
}
}
const hasContent = requests.length > 0 || brainstormShares.length > 0 || activeReminders.length > 0 || appNotifications.length > 0
// ── icon bg/color per notification type ──────────────────────────────────
const notifIconStyle = (type: string) => {
@@ -152,13 +181,17 @@ export function NotificationPanel() {
if (type === 'agent_slides_ready') return { bg: `${C.gold}20`, color: C.gold }
if (type === 'agent_canvas_ready') return { bg: `${C.gold}20`, color: C.gold }
if (type === 'agent_failure') return { bg: '#EF444420', color: '#EF4444' }
if (type === 'brainstorm_invite') return { bg: '#10b98120', color: '#10b981' }
if (type === 'brainstorm_joined') return { bg: '#60a5fa20', color: '#60a5fa' }
return { bg: `${C.green}20`, color: C.green }
}
const notifLabelColor = (type: string) => {
if (type.startsWith('agent')) {
if (type === 'agent_failure') return '#EF4444'
return C.gold
if (type === 'agent_failure') return '#EF4444'
if (type === 'brainstorm_invite') return '#10b981'
if (type === 'brainstorm_joined') return '#60a5fa'
return C.gold
}
return C.green
}
@@ -249,6 +282,8 @@ export function NotificationPanel() {
>
{isSlides ? <Presentation className="w-3.5 h-3.5" />
: isCanvas ? <Pencil className="w-3.5 h-3.5" />
: notif.type === 'brainstorm_invite' ? <Wind className="w-3.5 h-3.5" />
: notif.type === 'brainstorm_joined' ? <Wind className="w-3.5 h-3.5" />
: notif.type.startsWith('agent') ? <Bot className="w-3.5 h-3.5" />
: <AlertCircle className="w-3.5 h-3.5" />}
</div>
@@ -262,7 +297,9 @@ export function NotificationPanel() {
{notif.type === 'agent_canvas_ready' && (t('notification.canvasReady') || 'Diagramme prêt')}
{notif.type === 'agent_success' && (t('notification.agentSuccess') || 'Agent terminé')}
{notif.type === 'agent_failure' && (t('notification.agentFailed') || 'Agent échoué')}
{notif.type === 'system' && 'Système'}
{notif.type === 'brainstorm_invite' && (t('notification.brainstormInvite') || 'Brainstorm')}
{notif.type === 'brainstorm_joined' && (t('notification.brainstormJoined') || 'Brainstorm')}
{notif.type === 'system' && t('notification.systemNotification')}
</span>
<p className="text-[13px] font-semibold truncate mt-0.5">{notif.title}</p>
{notif.message && (
@@ -302,7 +339,7 @@ export function NotificationPanel() {
a.download = parsed.filename || `${data.canvas.name || 'presentation'}.pptx`
document.body.appendChild(a); a.click()
document.body.removeChild(a); URL.revokeObjectURL(url)
} catch { toast.error('Échec du téléchargement') }
} catch { toast.error(t('notification.downloadFailed')) }
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-[10px] font-bold rounded-lg text-white uppercase tracking-wide transition-all hover:opacity-90 active:scale-95 shadow-sm"
style={{ background: C.blue }}
@@ -362,6 +399,51 @@ export function NotificationPanel() {
</div>
))}
{/* ── Brainstorm share invites ── */}
{brainstormShares.map((share) => (
<div key={share.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">
<div className="flex items-start gap-3">
<div
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
style={{ background: `linear-gradient(135deg, #fb923c, #f97316)` }}
>
{(share.sharer?.name || share.sharer?.email || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-0.5">
<Wind className="w-3 h-3" style={{ color: '#fb923c' }} />
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#fb923c' }}>
Brainstorm
</span>
</div>
<p className="text-[13px] font-semibold truncate">
{share.sharer?.name || share.sharer?.email}
</p>
<p className="text-[11px] text-foreground/50 truncate">
{t('notification.brainstormShared') || 'invited you to a brainstorm'} « {share.session?.seedIdea?.length > 35 ? share.session.seedIdea.substring(0, 35) + '…' : share.session?.seedIdea} »
</p>
</div>
</div>
<div className="flex gap-2 ml-11">
<button
onClick={() => handleDeclineBrainstorm(share.id)}
className="flex-1 h-7 px-3 text-[11px] font-semibold rounded-lg border border-black/15 text-foreground/60 hover:bg-black/5 transition-all active:scale-95 flex items-center justify-center gap-1"
>
<X className="h-3 w-3" />
{t('notification.decline') || 'Decline'}
</button>
<button
onClick={() => handleAcceptBrainstorm(share.id)}
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
style={{ background: '#fb923c' }}
>
<Check className="h-3 w-3" />
{t('notification.accept') || 'Accept'}
</button>
</div>
</div>
))}
{/* ── Share requests ── */}
{requests.map((request) => (
<div key={request.id} className="p-4 hover:bg-black/[0.02] transition-colors space-y-3">

View File

@@ -11,6 +11,7 @@ import {
type OrganizationPlan,
} from '@/app/actions/organize-notebook'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface OrganizeNotebookDialogProps {
open: boolean
@@ -29,6 +30,8 @@ export function OrganizeNotebookDialog({
notebookName,
onDone,
}: OrganizeNotebookDialogProps) {
const { t, language } = useLanguage()
const organizePanelSlide = language === 'fa' || language === 'ar' ? -60 : 60
const [step, setStep] = useState<Step>('idle')
const [plan, setPlan] = useState<OrganizationPlan | null>(null)
const [editableGroups, setEditableGroups] = useState<OrganizationGroup[]>([])
@@ -44,7 +47,7 @@ export function OrganizeNotebookDialog({
const res = await analyzeNotebookForOrganization(notebookId)
if (!res.success || !res.plan) {
setError(res.error ?? 'Erreur inconnue')
setError(res.error ?? t('organizeNotebook.unknownError'))
setStep('idle')
return
}
@@ -94,14 +97,14 @@ export function OrganizeNotebookDialog({
const res = await executeNotebookOrganization(finalPlan)
if (!res.success) {
setError(res.error ?? 'Erreur inconnue')
setError(res.error ?? t('organizeNotebook.unknownError'))
setStep('preview')
return
}
setResult({ created: res.created, moved: res.moved })
setStep('done')
toast.success(`Carnet organisé — ${res.created} sous-carnet(s) créé(s), ${res.moved} note(s) déplacée(s)`)
toast.success(t('organizeNotebook.toastSuccess', { created: res.created, moved: res.moved }))
onDone?.()
}, [plan, editableGroups, onDone])
@@ -138,11 +141,11 @@ export function OrganizeNotebookDialog({
{/* Panel */}
<motion.div
initial={{ opacity: 0, x: 60 }}
initial={{ opacity: 0, x: organizePanelSlide }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 60 }}
exit={{ opacity: 0, x: organizePanelSlide }}
transition={{ type: 'spring', stiffness: 280, damping: 30 }}
className="fixed right-0 top-0 bottom-0 z-50 w-[460px] bg-card border-l border-border shadow-2xl flex flex-col"
className="fixed end-0 top-0 bottom-0 z-50 w-[460px] bg-card border-s border-border shadow-2xl flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header */}
@@ -152,7 +155,7 @@ export function OrganizeNotebookDialog({
<Sparkles size={16} className="text-blueprint" />
</div>
<div>
<h2 className="text-[13px] font-semibold text-ink">Organiser le carnet</h2>
<h2 className="text-[13px] font-semibold text-ink">{t('organizeNotebook.title')}</h2>
<p className="text-[11px] text-muted-ink truncate max-w-[240px]">{notebookName}</p>
</div>
</div>
@@ -186,14 +189,10 @@ export function OrganizeNotebookDialog({
)}
<div className="space-y-3">
<p className="text-[13px] text-ink leading-relaxed">
L'IA va analyser les notes de ce carnet et vous proposer un plan de réorganisation en sous-carnets thématiques.
{t('organizeNotebook.intro')}
</p>
<ul className="space-y-2">
{[
'Regroupement par sujet ou thème',
'Création de sous-carnets manquants',
'Aperçu complet avant modification',
].map(item => (
{[t('organizeNotebook.bulletThemes'), t('organizeNotebook.bulletSubfolders'), t('organizeNotebook.bulletPreview')].map(item => (
<li key={item} className="flex items-center gap-2 text-[12px] text-muted-ink">
<div className="w-1.5 h-1.5 rounded-full bg-blueprint shrink-0" />
{item}
@@ -224,8 +223,8 @@ export function OrganizeNotebookDialog({
/>
</div>
<div className="text-center space-y-1.5">
<p className="text-[14px] font-medium text-ink">Analyse en cours…</p>
<p className="text-[12px] text-muted-ink">L'IA lit vos notes et identifie les thèmes</p>
<p className="text-[14px] font-medium text-ink">{t('organizeNotebook.analyzingTitle')}</p>
<p className="text-[12px] text-muted-ink">{t('organizeNotebook.analyzingSubtitle')}</p>
</div>
<div className="flex gap-1.5">
{[0, 1, 2].map(i => (
@@ -253,7 +252,11 @@ export function OrganizeNotebookDialog({
<div className="flex items-center gap-3 p-3 rounded-xl bg-blueprint/5 border border-blueprint/20">
<Sparkles size={12} className="text-blueprint shrink-0" />
<p className="text-[11px] text-blueprint font-medium">
{editableGroups.length} groupe(s) · {totalNotes} note(s) · {newSubNbs} nouveau(x) sous-carnet(s)
{t('organizeNotebook.previewSummary', {
groups: editableGroups.length,
notes: totalNotes,
newSubs: newSubNbs,
})}
</p>
</div>
@@ -290,7 +293,7 @@ export function OrganizeNotebookDialog({
/>
{group.isNew && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-blueprint/10 text-blueprint shrink-0">
Nouveau
{t('organizeNotebook.badgeNew')}
</span>
)}
<div className="flex items-center gap-1 shrink-0">
@@ -329,7 +332,7 @@ export function OrganizeNotebookDialog({
<Check size={10} className="text-blueprint" />
</div>
<span className="text-[11px] text-muted-ink truncate group-hover:text-ink transition-colors">
{note.title || 'Note sans titre'}
{note.title || t('organizeNotebook.untitledNote')}
</span>
</div>
))}
@@ -341,7 +344,7 @@ export function OrganizeNotebookDialog({
{/* Collapsed count */}
{!expandedGroups.has(idx) && (
<div className="px-4 pb-2.5 text-[11px] text-muted-ink/60">
{group.notes.length} note(s)
{t('organizeNotebook.notesInGroup', { count: group.notes.length })}
</div>
)}
</motion.div>
@@ -360,8 +363,8 @@ export function OrganizeNotebookDialog({
>
<Loader2 size={32} className="text-blueprint animate-spin" />
<div className="text-center space-y-1">
<p className="text-[14px] font-medium text-ink">Organisation en cours</p>
<p className="text-[12px] text-muted-ink">Création des sous-carnets et déplacement des notes</p>
<p className="text-[14px] font-medium text-ink">{t('organizeNotebook.executingTitle')}</p>
<p className="text-[12px] text-muted-ink">{t('organizeNotebook.executingSubtitle')}</p>
</div>
</motion.div>
)}
@@ -378,10 +381,10 @@ export function OrganizeNotebookDialog({
<CheckCircle2 size={32} className="text-emerald-500" />
</div>
<div className="text-center space-y-1.5">
<p className="text-[15px] font-semibold text-ink">Carnet organisé !</p>
<p className="text-[15px] font-semibold text-ink">{t('organizeNotebook.doneTitle')}</p>
{result && (
<p className="text-[12px] text-muted-ink">
{result.created} sous-carnet(s) créé(s) · {result.moved} note(s) déplacée(s)
{t('organizeNotebook.doneStats', { created: result.created, moved: result.moved })}
</p>
)}
</div>
@@ -399,7 +402,7 @@ export function OrganizeNotebookDialog({
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-ink text-paper text-[13px] font-semibold hover:opacity-85 transition-opacity"
>
<Sparkles size={14} />
Analyser avec l'IA
{t('organizeNotebook.analyzeButton')}
</button>
)}
@@ -409,7 +412,7 @@ export function OrganizeNotebookDialog({
onClick={() => { setStep('idle'); setError(null) }}
className="flex-1 px-4 py-2.5 rounded-xl border border-border text-[13px] font-medium text-muted-ink hover:text-ink transition-colors"
>
Recommencer
{t('organizeNotebook.restart')}
</button>
<button
onClick={handleExecute}
@@ -422,7 +425,7 @@ export function OrganizeNotebookDialog({
)}
>
<Check size={14} />
Valider
{t('organizeNotebook.confirm')}
</button>
</div>
)}
@@ -432,7 +435,7 @@ export function OrganizeNotebookDialog({
onClick={handleClose}
className="w-full px-4 py-2.5 rounded-xl border border-border text-[13px] font-medium text-muted-ink hover:text-ink transition-colors"
>
Fermer
{t('organizeNotebook.closeButton')}
</button>
)}
</div>

View File

@@ -27,6 +27,7 @@ import {
Sun,
Pin,
PinOff,
Sparkles,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -48,8 +49,9 @@ import {
} from '@/components/ui/dropdown-menu'
import { signOut } from 'next-auth/react'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
type NavigationView = 'notebooks' | 'agents' | 'reminders'
type NavigationView = 'notebooks' | 'agents' | 'reminders' | 'brainstorms'
type SortOrder = 'newest' | 'oldest' | 'alpha'
function NoteLink({
@@ -61,13 +63,15 @@ function NoteLink({
isActive: boolean
onClick: () => void
}) {
const { language } = useLanguage()
const slideX = language === 'fa' || language === 'ar' ? 10 : -10
return (
<motion.button
initial={{ opacity: 0, x: -10 }}
initial={{ opacity: 0, x: slideX }}
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={cn(
'w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg text-left',
'w-full flex items-center gap-2 ps-12 pe-4 py-2 text-[12px] transition-colors rounded-lg text-start',
isActive ? 'bg-white/50 text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-white/30'
)}
>
@@ -80,6 +84,81 @@ function NoteLink({
)
}
function SidebarBrainstorms() {
const { data: sessions, isLoading } = useBrainstormSessions()
const deleteBrainstorm = useDeleteBrainstorm()
const router = useRouter()
const { t } = useLanguage()
if (isLoading) {
return (
<div className="px-4 space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-12 rounded-xl bg-paper/50 animate-pulse" />
))}
</div>
)
}
if (!sessions || sessions.length === 0) {
return (
<div className="px-4 py-6 text-center">
<Sparkles size={20} className="mx-auto text-orange-400/40 mb-2" />
<p className="text-[11px] text-muted-foreground">{t('brainstorm.noSessions')}</p>
<button
onClick={() => router.push('/brainstorm')}
className="mt-2 text-[11px] text-orange-500 hover:text-orange-400 font-medium"
>
{t('brainstorm.startOne')}
</button>
</div>
)
}
return (
<div className="space-y-0.5">
{sessions.slice(0, 10).map(s => (
<div key={s.id} className="relative group/item">
<button
onClick={() => router.replace(`/brainstorm?session=${s.id}`)}
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 text-start hover:bg-memento-blue/5 group ${
(s as any)._owned === false ? 'border-s-2 border-memento-blue/30 dark:border-memento-blue/70' : ''
}`}
>
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${
(s as any)._owned === false
? 'border border-memento-blue/20 dark:border-memento-blue/80 bg-memento-blue/5 dark:bg-memento-blue/20'
: 'border border-orange-200 dark:border-orange-800/40 bg-orange-50 dark:bg-orange-900/20'
}`}>
<Sparkles size={12} className={(s as any)._owned === false ? 'text-memento-blue' : 'text-orange-500'} />
</div>
<div className="min-w-0 flex-1">
<p className="text-[12px] font-medium truncate">
{s.seedIdea}
{(s as any)._owned === false && <span className="text-[9px] ms-1.5 text-memento-blue/70 font-normal">· partagé</span>}
</p>
<p className="text-[10px] text-muted-foreground">
{s.activeIdeas} {t('brainstorm.ideas')} · {new Date(s.createdAt).toLocaleDateString()}
</p>
</div>
</button>
{(s as any)._owned !== false && (
<button
onClick={(e) => {
e.stopPropagation()
deleteBrainstorm.mutate(s.id)
}}
className="absolute end-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg opacity-0 group-hover/item:opacity-100 hover:bg-rose-500/10 text-muted-foreground hover:text-rose-500 transition-all"
>
<Trash2 size={13} />
</button>
)}
</div>
))}
</div>
)
}
function SidebarCarnetItem({
carnet,
isActive,
@@ -117,7 +196,8 @@ function SidebarCarnetItem({
isExpanded: boolean
toggleExpand: () => void
}) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const hasChildren = React.Children.count(children) > 0
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
@@ -133,7 +213,7 @@ function SidebarCarnetItem({
<div className={cn('transition-opacity', isDragging && 'opacity-40')}>
<div
className="flex items-center group relative h-10"
style={{ paddingLeft: `${level * 16}px` }}
style={{ paddingInlineStart: `${level * 16}px` }}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
@@ -141,15 +221,15 @@ function SidebarCarnetItem({
}}
>
{level > 0 && (
<div className="absolute left-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
<div className="absolute start-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
)}
{level > 0 && (
<div className="absolute left-[8px] top-1/2 w-[8px] h-px bg-border/40" />
<div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />
)}
<div
{...dragHandleProps}
className="absolute left-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity z-10"
className="absolute start-1 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground/30 hover:text-muted-foreground cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<GripVertical size={12} />
</div>
@@ -161,7 +241,7 @@ function SidebarCarnetItem({
className="p-1 hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground"
>
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
<ChevronRight size={14} />
<ChevronRight size={14} className="rtl:scale-x-[-1]" />
</motion.div>
</button>
) : (
@@ -169,7 +249,7 @@ function SidebarCarnetItem({
)}
<motion.div
whileHover={{ x: 2 }}
whileHover={{ x: isRtl ? -2 : 2 }}
onClick={onCarnetClick}
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
className={cn(
@@ -180,7 +260,7 @@ function SidebarCarnetItem({
{isActive && (
<motion.div
layoutId="active-indicator"
className="absolute -left-1 w-1 h-4 bg-blueprint rounded-full"
className="absolute -start-1 w-1 h-4 bg-blueprint rounded-full"
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
/>
)}
@@ -193,7 +273,7 @@ function SidebarCarnetItem({
{carnet.initial}
</div>
<div className="flex-1 text-left flex items-center gap-2 min-w-0">
<div className="flex-1 text-start flex items-center gap-2 min-w-0">
<span className={cn(
'text-[12px] font-medium transition-colors truncate',
isActive ? 'text-ink' : 'text-muted-ink group-hover/item:text-ink'
@@ -205,7 +285,7 @@ function SidebarCarnetItem({
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
{isPinned && (
<span className="text-blueprint" title="Carnet figé">
<span className="text-blueprint" title={t('notebook.pinnedFrozenTooltip')}>
<Pin size={9} className="opacity-70" />
</span>
)}
@@ -258,8 +338,8 @@ function SidebarCarnetItem({
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
>
{isPinned
? <><PinOff size={13} className="text-blueprint" /><span>Défiger l'état du carnet</span></>
: <><Pin size={13} className="text-blueprint" /><span>Figer l'état du carnet</span></>
? <><PinOff size={13} className="text-blueprint" /><span>{t('sidebar.unfreezePinnedNotebook')}</span></>
: <><Pin size={13} className="text-blueprint" /><span>{t('sidebar.freezePinnedNotebook')}</span></>
}
</button>
<div className="mx-3 my-1 border-t border-border/50" />
@@ -268,14 +348,14 @@ function SidebarCarnetItem({
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
>
<Plus size={13} className="text-concrete" />
<span>Nouveau sous-carnet</span>
<span>{t('sidebar.newSubNotebook')}</span>
</button>
<button
onClick={() => { onRename(); setContextMenu(null) }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors"
>
<Pencil size={13} className="text-concrete" />
<span>Renommer</span>
<span>{t('sidebar.renameNotebook')}</span>
</button>
<div className="mx-3 my-1 border-t border-border/50" />
<button
@@ -283,7 +363,7 @@ function SidebarCarnetItem({
className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30 transition-colors"
>
<Trash2 size={13} />
<span>Supprimer</span>
<span>{t('common.delete')}</span>
</button>
</motion.div>
)}
@@ -298,8 +378,8 @@ function SidebarCarnetItem({
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<div className="relative" style={{ marginLeft: `${(level + 1) * 16 + 10}px` }}>
<div className="absolute left-[-6px] top-0 bottom-4 w-px bg-border/30" />
<div className="relative" style={{ marginInlineStart: `${(level + 1) * 16 + 10}px` }}>
<div className="absolute start-[-6px] top-0 bottom-4 w-px bg-border/30" />
<div className="space-y-0.5 py-1">
{children}
@@ -312,7 +392,7 @@ function SidebarCarnetItem({
/>
))}
{isActive && notes.length === 0 && !hasChildren && (
<p className="pl-8 py-2 text-[10px] italic text-muted-foreground/40 font-light">
<p className="ps-8 py-2 text-[10px] italic text-muted-foreground/40 font-light">
{t('common.noResults') || 'No notes found'}
</p>
)}
@@ -329,7 +409,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
const { notebooks, trashNotebook, updateNotebookOrderOptimistic } = useNotebooks()
const { refreshKey } = useNoteRefresh()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
@@ -733,7 +814,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
className="text-destructive focus:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="h-4 w-4 mr-2" />
<LogOut className="h-4 w-4 me-2" />
{t('sidebar.signOut') || 'Se déconnecter'}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -770,6 +851,13 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
>
<Bot size={14} />
</button>
<button
onClick={() => { setActiveView('brainstorms'); router.push('/brainstorm') }}
className={cn('p-1.5 rounded-full transition-all', activeView === 'brainstorms' ? 'bg-orange-500 text-white shadow-sm' : 'text-muted-ink hover:text-ink')}
title={t('brainstorm.sessions')}
>
<Sparkles size={14} />
</button>
</div>
</div>
</div>
@@ -781,9 +869,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{activeView === 'notebooks' ? (
<motion.div
key="notebooks"
initial={{ opacity: 0, x: -10 }}
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
>
{/* Section header with sort button */}
@@ -795,7 +883,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<button
onClick={() => { setCreateParentId(null); setIsCreateDialogOpen(true) }}
className="p-1 text-muted-foreground hover:text-foreground hover:bg-white/40 transition-all rounded"
title={t('notebook.create') || 'Nouveau carnet'}
title={t('notebook.create')}
>
<Plus size={12} />
</button>
@@ -813,14 +901,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
initial={{ opacity: 0, scale: 0.9, y: -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -4 }}
className="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
className="absolute end-0 top-full mt-1 bg-card border border-border rounded-xl shadow-lg z-50 py-1 min-w-[140px]"
>
{(['newest', 'oldest', 'alpha'] as SortOrder[]).map(order => (
<button
key={order}
onClick={() => { setSortOrder(order); setShowSortMenu(false) }}
className={cn(
'w-full text-left px-4 py-2 text-[12px] transition-colors',
'w-full text-start px-4 py-2 text-[12px] transition-colors',
sortOrder === order
? 'font-bold text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
@@ -872,9 +960,9 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
) : activeView === 'reminders' ? (
<motion.div
key="reminders"
initial={{ opacity: 0, x: -10 }}
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
transition={{ duration: 0.2 }}
>
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase mb-4 px-4">
@@ -885,12 +973,12 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<p className="text-[11px] text-concrete italic">{t('sidebar.noReminders') || 'No active reminders.'}</p>
</div>
</motion.div>
) : (
) : activeView === 'agents' ? (
<motion.div
key="agents"
initial={{ opacity: 0, x: 10 }}
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
exit={{ opacity: 0, x: isRtl ? 10 : -10 }}
transition={{ duration: 0.2 }}
>
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase mb-4 px-4">
@@ -900,6 +988,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{[
{ id: 'agents', href: '/agents', label: t('agents.myAgents'), icon: Bot },
{ id: 'lab', href: '/lab', label: t('nav.lab'), icon: FlaskConical },
{ id: 'brainstorm', href: '/brainstorm', label: t('brainstorm.sessions'), icon: Sparkles },
].map(item => {
const isActive = pathname.startsWith(item.href)
return (
@@ -925,6 +1014,28 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
})}
</div>
</motion.div>
) : (
<motion.div
key="brainstorms"
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isRtl ? 10 : -10 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between px-4 mb-3">
<p className="text-[10px] font-bold text-muted-foreground tracking-widest uppercase">
{t('brainstorm.sessions')}
</p>
<button
onClick={() => router.push('/brainstorm')}
className="p-1 text-muted-foreground hover:text-orange-500 transition-colors rounded"
title={t('brainstorm.newBrainstorm')}
>
<Plus size={12} />
</button>
</div>
<SidebarBrainstorms />
</motion.div>
)}
</AnimatePresence>
</div>
@@ -965,7 +1076,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Trash2 size={14} className={pathname === '/trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
<span>{t('sidebar.trash')}</span>
{trashCount > 0 && (
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-rose-400" />
<span className="ms-auto w-1.5 h-1.5 rounded-full bg-rose-400" />
)}
</Link>

View File

@@ -69,7 +69,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 end-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
@@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
className={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...props}
/>
)