Built the Oracle Tab (#14)

This commit is contained in:
2026-04-11 19:35:45 +05:30
committed by Sagnik
parent 8e1ffe0e43
commit fb656d1443
54 changed files with 10651 additions and 818 deletions

View File

@@ -0,0 +1,261 @@
/**
* ShareModal — Fork-based sharing workflow.
* Explains the direct_fork_only semantics (recipient gets an editable copy,
* not live edit access to owner's canvas).
*/
import { useState, useEffect } 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';
interface ShareModalProps {
page: CanvasPage | null;
isOpen: boolean;
onClose: () => void;
onShare: (params: {
recipientEmail: string;
visibility: 'private' | 'team';
message: string;
sourceRevision: number;
}) => Promise<void>;
}
const TEAM_MEMBERS = [
{ id: 'u2', name: 'Elena Rostova', email: 'elena@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
{ id: 'u3', name: 'Priya Sharma', email: 'priya@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
{ id: 'u4', name: 'Carlos Mendez', email: 'carlos@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
{ id: 'u5', name: 'Ravi Kapoor', email: 'ravi@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
];
export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const [recipient, setRecipient] = useState<{ id: string; name: string; email: string } | null>(null);
const [visibility, setVisibility] = useState<'private' | 'team'>('private');
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [memberDropOpen, setMemberDropOpen] = useState(false);
const handleShare = async () => {
if (!recipient || !page) return;
setSubmitting(true);
try {
await onShare({
recipientEmail: recipient.email,
visibility,
message,
sourceRevision: page.headRevision,
});
setSuccess(true);
setTimeout(() => {
setSuccess(false);
onClose();
setRecipient(null);
setMessage('');
}, 2000);
} catch {
// stay open on error
} finally {
setSubmitting(false);
}
};
const content = (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<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)',
}}
>
{/* Header */}
<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>
{/* Fork explanation */}
<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> an editable copy
of this canvas at revision {page?.headRevision}. They can build on it and open a merge
request to propose their changes back.
</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?.name} can now access their copy.</p>
</div>
) : (
<div className="space-y-4">
{/* Recipient picker */}
<div>
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Recipient</label>
<div className="relative">
<button
onClick={() => setMemberDropOpen((p) => !p)}
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>{recipient?.name ?? 'Select team member…'}</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 }}
>
{TEAM_MEMBERS.map((m) => (
<button
key={m.id}
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
onClick={() => { setRecipient(m); setMemberDropOpen(false); }}
>
<img src={m.avatar} className="w-7 h-7 rounded-full" alt={m.name} />
<div>
<p className="text-sm text-zinc-200">{m.name}</p>
<p className="text-[10px] text-zinc-500">{m.role}</p>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Visibility */}
<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>
{/* Message */}
<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>
{/* Actions */}
<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);
}