Files
Momento/memento-note/components/brainstorm/brainstorm-share-dialog.tsx
Antigravity 8c7ca69640
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup
- Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders
- Fix all SQL embedding queries: add ::vector cast on text columns
- Fix embedding truncation to 15000 chars (under 8192 token limit)
- Fix NoteEmbedding INSERT: remove non-existent updatedAt column
- Fix billing page: show all quota stats in grid instead of single metric
- Fix usage meter: accordion expand/collapse, per-feature detail
- Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch
- Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
2026-05-16 18:50:34 +00:00

299 lines
11 KiB
TypeScript

'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'
import { useLanguage } from '@/lib/i18n'
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 FEEDBACK_BY_MESSAGE: Record<string, { key: string; type: 'success' | 'info' }> = {
invited: { key: 'brainstorm.feedbackInviteSent', type: 'success' },
re_invited: { key: 'brainstorm.feedbackInviteResent', type: 'success' },
already_shared: {
key: 'brainstorm.feedbackAlreadyShared',
type: 'info',
},
already_pending: {
key: 'brainstorm.feedbackAlreadyPending',
type: 'info',
},
}
export function BrainstormShareDialog({
open,
onOpenChange,
sessionId,
seedIdea,
isPublic = false,
guestCanEdit = false,
}: BrainstormShareDialogProps) {
const { t } = useLanguage()
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 && FEEDBACK_BY_MESSAGE[result.message]) {
const m = FEEDBACK_BY_MESSAGE[result.message]
setFeedback({ text: t(m.key), type: m.type })
} else {
setFeedback({ text: t('brainstorm.feedbackInviteSent'), type: 'success' })
}
if (result.message === 'invited' || result.message === 're_invited') {
setQuery('')
}
} catch (err: any) {
setFeedback({ text: err.message || t('brainstorm.feedbackGenericError'), 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-brand-accent/10 flex items-center justify-center">
<UserPlus size={14} className="text-brand-accent" />
</div>
<span className="font-serif">{t('brainstorm.shareDialogTitle')}</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">
{t('brainstorm.shareSearchLabel')}
</label>
<input
type="text"
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => results.length > 0 && setShowDropdown(true)}
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
placeholder={t('brainstorm.shareNameOrEmailPlaceholder')}
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/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-brand-accent/5 transition-colors text-left"
>
<div className="w-8 h-8 rounded-full bg-brand-accent/10 flex items-center justify-center text-xs font-bold text-brand-accent 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 || t('brainstorm.unnamedPerson')}
</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-brand-accent hover:bg-brand-accent/90 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 ? t('brainstorm.shareSubmitting') : t('brainstorm.shareSubmit')}
</button>
</form>
<p className="text-[10px] text-muted-foreground/60 text-center mt-1">
{t('brainstorm.shareFooterHint')}
</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">
{t('brainstorm.sharePublicLink')}
</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">
{t('brainstorm.shareGuestsCanEdit')}
</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>
)
}