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