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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user