All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- 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
299 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|