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
227 lines
9.3 KiB
TypeScript
227 lines
9.3 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { createShareRequest, removeCollaborator, getNoteCollaborators } from '@/app/actions/notes'
|
|
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
|
|
name: string | null
|
|
email: string | null
|
|
image: string | null
|
|
}
|
|
|
|
interface NoteShareDialogProps {
|
|
noteId: string
|
|
noteTitle: string
|
|
onClose: () => void
|
|
}
|
|
|
|
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[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [sending, setSending] = useState(false)
|
|
const [removingId, setRemovingId] = useState<string | null>(null)
|
|
const [sent, setSent] = useState(false)
|
|
const [mounted, setMounted] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
// Close on Escape
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [onClose])
|
|
|
|
const loadCollaborators = useCallback(async () => {
|
|
try {
|
|
const list = await getNoteCollaborators(noteId)
|
|
setCollaborators(list as Collaborator[])
|
|
} catch {
|
|
// owner-only view — silently ignore
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [noteId])
|
|
|
|
useEffect(() => { loadCollaborators() }, [loadCollaborators])
|
|
|
|
const handleInvite = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
const trimmed = email.trim()
|
|
if (!trimmed) return
|
|
setSending(true)
|
|
try {
|
|
await createShareRequest(noteId, trimmed, permission)
|
|
setSent(true)
|
|
setEmail('')
|
|
toast.success(t('collaboration.toastInviteSentTo', { email: trimmed }))
|
|
setTimeout(() => setSent(false), 2000)
|
|
loadCollaborators()
|
|
} catch (err: any) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
const handleRemove = async (collaboratorId: string, collaboratorEmail: string | null) => {
|
|
setRemovingId(collaboratorId)
|
|
try {
|
|
await removeCollaborator(noteId, collaboratorId)
|
|
setCollaborators(prev => prev.filter(c => c.id !== collaboratorId))
|
|
toast.success(t('collaboration.toastAccessRemoved', {
|
|
target: collaboratorEmail || t('collaboration.toastUserFallback'),
|
|
}))
|
|
} catch {
|
|
toast.error(t('collaboration.toastRemoveAccessFailed'))
|
|
} finally {
|
|
setRemovingId(null)
|
|
}
|
|
}
|
|
|
|
if (!mounted) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
|
>
|
|
<div
|
|
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl w-full max-w-md mx-4 overflow-hidden border border-black/10 dark:border-white/10"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* 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-[#A47148]" />
|
|
<h2 className="text-sm font-bold text-foreground tracking-tight">{t('collaboration.shareCompactTitle')}</h2>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-foreground/40 hover:text-foreground transition-colors"
|
|
>
|
|
<X size={15} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 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">
|
|
{t('collaboration.inviteByEmailLabel')}
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<div className="relative flex-1">
|
|
<Mail size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/30" />
|
|
<input
|
|
type="email"
|
|
placeholder="email@example.com"
|
|
value={email}
|
|
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-[#A47148]/30 focus:border-[#A47148] transition-all placeholder:text-foreground/30"
|
|
/>
|
|
</div>
|
|
{/* Permission toggle */}
|
|
<div className="flex rounded-xl border border-black/15 dark:border-white/15 overflow-hidden shrink-0">
|
|
{(['view', 'edit'] as const).map(p => (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => setPermission(p)}
|
|
className={cn(
|
|
'px-3 py-2 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
|
permission === p
|
|
? 'bg-[#A47148] text-white'
|
|
: 'text-foreground/50 hover:bg-black/5 dark:hover:bg-white/5'
|
|
)}
|
|
>
|
|
{p === 'view' ? t('collaboration.accessReadCompact') : t('collaboration.accessEditCompact')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={sending || !email.trim()}
|
|
className={cn(
|
|
'w-full py-2.5 rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 transition-all',
|
|
sending || !email.trim()
|
|
? 'bg-black/5 dark:bg-white/5 text-foreground/30 cursor-not-allowed'
|
|
: sent
|
|
? 'bg-emerald-500 text-white'
|
|
: 'bg-[#A47148] text-white hover:opacity-90 shadow-sm shadow-[#A47148]/30'
|
|
)}
|
|
>
|
|
{sending
|
|
? <Loader2 size={13} className="animate-spin" />
|
|
: sent
|
|
? <><Check size={13} /> {t('collaboration.invitationSentBadge')}</>
|
|
: <><UserPlus size={13} /> {t('collaboration.sendInvitation')}</>
|
|
}
|
|
</button>
|
|
</form>
|
|
|
|
{/* Collaborators list */}
|
|
<div className="px-6 pb-6 space-y-3">
|
|
<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} /> {t('collaboration.sharedAccessLabel')}
|
|
</span>
|
|
<div className="h-px flex-1 bg-black/10 dark:bg-white/10" />
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-4">
|
|
<Loader2 size={16} className="animate-spin text-foreground/30" />
|
|
</div>
|
|
) : collaborators.length === 0 ? (
|
|
<p className="text-center text-[11px] text-foreground/30 py-4">
|
|
{t('collaboration.noCollaboratorsEmpty')}
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-2">
|
|
{collaborators.map(c => (
|
|
<li key={c.id} className="flex items-center gap-3 p-2.5 rounded-xl bg-black/[0.03] dark:bg-white/[0.03] border border-black/[0.06] dark:border-white/[0.06]">
|
|
<div className="h-8 w-8 rounded-full bg-[#E9ECEF]/20 flex items-center justify-center shrink-0 overflow-hidden">
|
|
{c.image
|
|
? <img src={c.image} alt={c.name || ''} className="h-full w-full object-cover" />
|
|
: <span className="text-[11px] font-bold text-[#E9ECEF]">{(c.name || c.email || '?')[0].toUpperCase()}</span>
|
|
}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<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={t('collaboration.removeAccessTitle')}
|
|
>
|
|
{removingId === c.id ? <Loader2 size={13} className="animate-spin" /> : <Trash2 size={13} />}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)
|
|
}
|