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
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:
@@ -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> = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
120
memento-note/components/brainstorm/activity-feed.tsx
Normal file
120
memento-note/components/brainstorm/activity-feed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
370
memento-note/components/brainstorm/brainstorm-canvas.tsx
Normal file
370
memento-note/components/brainstorm/brainstorm-canvas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
817
memento-note/components/brainstorm/brainstorm-page.tsx
Normal file
817
memento-note/components/brainstorm/brainstorm-page.tsx
Normal 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">
|
||||
“{selectedIdea.connectionToSeed}”
|
||||
</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>
|
||||
)
|
||||
}
|
||||
296
memento-note/components/brainstorm/brainstorm-share-dialog.tsx
Normal file
296
memento-note/components/brainstorm/brainstorm-share-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
memento-note/components/brainstorm/ghost-cursor.tsx
Normal file
123
memento-note/components/brainstorm/ghost-cursor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
210
memento-note/components/brainstorm/invite-dialog.tsx
Normal file
210
memento-note/components/brainstorm/invite-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
memento-note/components/brainstorm/live-cursors.tsx
Normal file
93
memento-note/components/brainstorm/live-cursors.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
memento-note/components/brainstorm/manual-idea-dialog.tsx
Normal file
98
memento-note/components/brainstorm/manual-idea-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
197
memento-note/components/brainstorm/playback-bar.tsx
Normal file
197
memento-note/components/brainstorm/playback-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
585
memento-note/components/brainstorm/wave-canvas.tsx
Normal file
585
memento-note/components/brainstorm/wave-canvas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
211
memento-note/components/document-qa-overlay.tsx
Normal file
211
memento-note/components/document-qa-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
247
memento-note/components/note-attachments.tsx
Normal file
247
memento-note/components/note-attachments.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'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'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 don’t 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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user