356 lines
15 KiB
TypeScript
356 lines
15 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { X, Share2, GitFork, Lock, Users, MessageSquare, ChevronDown } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import type { CanvasPage } from '../types/canvas';
|
|
import { listVelocityUsers, type VelocityActiveUser } from '@/lib/velocityPlatformClient';
|
|
|
|
interface ShareModalProps {
|
|
page: CanvasPage | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
currentUserId?: string | null;
|
|
onShare: (params: {
|
|
recipientUserId: string;
|
|
visibility: 'private' | 'team';
|
|
message: string;
|
|
sourceRevision: number;
|
|
}) => Promise<void>;
|
|
}
|
|
|
|
function getDisplayName(member: VelocityActiveUser): string {
|
|
return member.full_name?.trim() || member.email?.trim() || member.user_id;
|
|
}
|
|
|
|
function getRoleLabel(member: VelocityActiveUser): string {
|
|
return member.role
|
|
.toLowerCase()
|
|
.split('_')
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
function getInitials(member: VelocityActiveUser): string {
|
|
const basis = getDisplayName(member);
|
|
return basis
|
|
.split(/[\s@._-]+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part.charAt(0).toUpperCase())
|
|
.join('') || 'U';
|
|
}
|
|
|
|
export function ShareModal({ page, isOpen, onClose, currentUserId, onShare }: ShareModalProps) {
|
|
const [mounted, setMounted] = useState(false);
|
|
const [teamMembers, setTeamMembers] = useState<VelocityActiveUser[]>([]);
|
|
const [loadingMembers, setLoadingMembers] = useState(false);
|
|
const [membersError, setMembersError] = useState<string | null>(null);
|
|
const [recipient, setRecipient] = useState<VelocityActiveUser | null>(null);
|
|
const [visibility, setVisibility] = useState<'private' | 'team'>('private');
|
|
const [message, setMessage] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [success, setSuccess] = useState(false);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
const [memberDropOpen, setMemberDropOpen] = useState(false);
|
|
|
|
useEffect(() => setMounted(true), []);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setMemberDropOpen(false);
|
|
setSubmitError(null);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setLoadingMembers(true);
|
|
setMembersError(null);
|
|
|
|
void listVelocityUsers()
|
|
.then((users) => {
|
|
if (cancelled) return;
|
|
setTeamMembers(users);
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) return;
|
|
setMembersError(error instanceof Error ? error.message : 'Failed to load team members.');
|
|
setTeamMembers([]);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoadingMembers(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isOpen]);
|
|
|
|
const availableMembers = useMemo(
|
|
() => teamMembers.filter((member) => member.user_id !== currentUserId),
|
|
[teamMembers, currentUserId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (recipient && recipient.user_id === currentUserId) {
|
|
setRecipient(null);
|
|
}
|
|
}, [recipient, currentUserId]);
|
|
|
|
const selectedRecipientLabel = useMemo(
|
|
() => (recipient ? getDisplayName(recipient) : 'Select verified teammate...'),
|
|
[recipient],
|
|
);
|
|
|
|
const handleShare = async () => {
|
|
if (!recipient || !page) return;
|
|
setSubmitting(true);
|
|
setSubmitError(null);
|
|
try {
|
|
await onShare({
|
|
recipientUserId: recipient.user_id,
|
|
visibility,
|
|
message,
|
|
sourceRevision: page.headRevision,
|
|
});
|
|
setSuccess(true);
|
|
setTimeout(() => {
|
|
setSuccess(false);
|
|
onClose();
|
|
setRecipient(null);
|
|
setMessage('');
|
|
}, 1800);
|
|
} catch (error) {
|
|
setSubmitError(error instanceof Error ? error.message : 'Share failed.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const content = (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<>
|
|
<motion.div
|
|
key="share-backdrop"
|
|
className="fixed inset-0 z-40"
|
|
style={{ background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)' }}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={onClose}
|
|
/>
|
|
|
|
<motion.div
|
|
key="share-modal"
|
|
className="fixed z-50 left-1/2 top-1/2 w-full max-w-md"
|
|
initial={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
|
|
animate={{ opacity: 1, scale: 1, x: '-50%', y: '-50%' }}
|
|
exit={{ opacity: 0, scale: 0.92, x: '-50%', y: '-50%' }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
<div
|
|
className="rounded-2xl p-6"
|
|
style={{
|
|
background: 'rgba(12, 13, 20, 0.98)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between mb-5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-xl bg-blue-500/15 border border-blue-500/25 flex items-center justify-center">
|
|
<Share2 className="w-4 h-4 text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-semibold text-zinc-100">Share Canvas</h2>
|
|
<p className="text-xs text-zinc-500">
|
|
{page?.title ?? 'Oracle Canvas'} · rev.{page?.headRevision}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button onClick={onClose} className="text-zinc-600 hover:text-zinc-300 transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
className="flex items-start gap-3 p-3 rounded-xl mb-5"
|
|
style={{ background: 'rgba(59,130,246,0.07)', border: '1px solid rgba(59,130,246,0.18)' }}
|
|
>
|
|
<GitFork className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
|
<p className="text-xs text-zinc-300 leading-relaxed">
|
|
The recipient gets a <span className="text-blue-300 font-medium">fork</span> of this canvas at the selected revision.
|
|
They can edit their copy and later open a merge request back into the source branch.
|
|
</p>
|
|
</div>
|
|
|
|
{success ? (
|
|
<div className="flex flex-col items-center gap-3 py-6">
|
|
<div className="w-12 h-12 rounded-full bg-green-500/15 border border-green-500/30 flex items-center justify-center">
|
|
<Share2 className="w-6 h-6 text-green-400" />
|
|
</div>
|
|
<p className="text-sm text-zinc-200 font-medium">Fork created successfully.</p>
|
|
<p className="text-xs text-zinc-500">{recipient ? getDisplayName(recipient) : 'Recipient'} can access the shared copy.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{submitError && (
|
|
<div
|
|
className="rounded-xl px-3 py-2 text-xs text-red-300"
|
|
style={{
|
|
background: 'rgba(239,68,68,0.08)',
|
|
border: '1px solid rgba(239,68,68,0.2)',
|
|
}}
|
|
>
|
|
{submitError}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Recipient</label>
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setMemberDropOpen((prev) => !prev)}
|
|
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm"
|
|
style={{
|
|
background: 'rgba(255,255,255,0.04)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
color: recipient ? '#e4e4e7' : '#71717a',
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-3.5 h-3.5 text-zinc-500" />
|
|
<span>{selectedRecipientLabel}</span>
|
|
</div>
|
|
<ChevronDown
|
|
className="w-3.5 h-3.5 text-zinc-600"
|
|
style={{ transform: memberDropOpen ? 'rotate(180deg)' : 'none' }}
|
|
/>
|
|
</button>
|
|
|
|
<AnimatePresence>
|
|
{memberDropOpen && (
|
|
<motion.div
|
|
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-xl py-1 overflow-hidden shadow-2xl"
|
|
style={{ background: 'rgb(14,15,22)', border: '1px solid rgba(255,255,255,0.12)' }}
|
|
initial={{ opacity: 0, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
>
|
|
{loadingMembers && (
|
|
<div className="px-3 py-3 text-xs text-zinc-500">Loading verified accounts...</div>
|
|
)}
|
|
{!loadingMembers && membersError && (
|
|
<div className="px-3 py-3 text-xs text-red-400">{membersError}</div>
|
|
)}
|
|
{!loadingMembers && !membersError && availableMembers.length === 0 && (
|
|
<div className="px-3 py-3 text-xs text-zinc-500">No verified users available.</div>
|
|
)}
|
|
{!loadingMembers && !membersError && availableMembers.map((member) => (
|
|
<button
|
|
key={member.user_id}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
|
|
onClick={() => {
|
|
setRecipient(member);
|
|
setMemberDropOpen(false);
|
|
}}
|
|
>
|
|
{member.avatar_url ? (
|
|
<img
|
|
src={member.avatar_url}
|
|
alt={getDisplayName(member)}
|
|
className="w-7 h-7 rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-7 h-7 rounded-full bg-blue-500/15 border border-blue-500/25 text-[10px] font-semibold text-blue-300 flex items-center justify-center">
|
|
{getInitials(member)}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0">
|
|
<p className="text-sm text-zinc-200 truncate">{getDisplayName(member)}</p>
|
|
<p className="text-[10px] text-zinc-500 truncate">
|
|
{member.email || member.user_id} · {getRoleLabel(member)}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Fork visibility</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{([
|
|
{ value: 'private', icon: Lock, label: 'Private', desc: 'Only recipient' },
|
|
{ value: 'team', icon: Users, label: 'Team', desc: 'Whole team' },
|
|
] as const).map(({ value, icon: Icon, label, desc }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => setVisibility(value)}
|
|
className="flex items-center gap-2 p-3 rounded-xl text-left transition-all"
|
|
style={{
|
|
background: visibility === value ? 'rgba(59,130,246,0.12)' : 'rgba(255,255,255,0.03)',
|
|
border: `1px solid ${visibility === value ? 'rgba(59,130,246,0.35)' : 'rgba(255,255,255,0.08)'}`,
|
|
}}
|
|
>
|
|
<Icon className="w-4 h-4" style={{ color: visibility === value ? '#60a5fa' : '#52525b' }} />
|
|
<div>
|
|
<p className="text-xs font-medium" style={{ color: visibility === value ? '#93c5fd' : '#a1a1aa' }}>{label}</p>
|
|
<p className="text-[10px] text-zinc-600">{desc}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-zinc-400 mb-1.5 flex items-center gap-1.5">
|
|
<MessageSquare className="w-3 h-3" />
|
|
Message <span className="text-zinc-600">(optional)</span>
|
|
</label>
|
|
<textarea
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
placeholder="Add context for the recipient..."
|
|
rows={3}
|
|
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
|
|
style={{
|
|
background: 'rgba(255,255,255,0.04)',
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
color: '#e4e4e7',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-1">
|
|
<Button
|
|
variant="outline"
|
|
onClick={onClose}
|
|
className="flex-1 border-white/10 bg-white/5 hover:bg-white/10 text-zinc-300"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => void handleShare()}
|
|
disabled={!recipient || submitting}
|
|
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white"
|
|
>
|
|
{submitting ? 'Creating fork...' : 'Share (Create Fork)'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
|
|
if (!mounted) return null;
|
|
return createPortal(content, document.body);
|
|
}
|