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
124 lines
4.1 KiB
TypeScript
124 lines
4.1 KiB
TypeScript
'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>
|
|
)
|
|
}
|