feat: Oracle Canvas, Revision History and Canvas Sharing (#33)

Co-authored-by: Sagnik <sagnik7896@gmail.com>
Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
2026-04-23 01:20:21 +05:30
parent e519339cc9
commit 6cdc366718
58 changed files with 3187 additions and 705 deletions

View File

@@ -454,6 +454,7 @@ export default function OraclePage() {
page={page}
isOpen={shareOpen}
onClose={() => setShareOpen(false)}
currentUserId={me?.userId ?? null}
onShare={handleShare}
/>

View File

@@ -39,7 +39,35 @@ function groupBySection(components: CanvasComponent[]): Array<{ sectionId: strin
sectionMap.get(sid)!.push(comp);
}
return Array.from(sectionMap.entries()).map(([sectionId, comps]) => ({ sectionId, components: comps }));
return Array.from(sectionMap.entries())
.map(([sectionId, comps]) => ({ sectionId, components: comps }))
.sort((a, b) => {
const aPrompt = a.sectionId.startsWith('sec_prompt_generated');
const bPrompt = b.sectionId.startsWith('sec_prompt_generated');
if (aPrompt && bPrompt) {
const aCreated = Math.max(...a.components.map((comp) => Date.parse(comp.provenance.createdAt || '1970-01-01T00:00:00Z')));
const bCreated = Math.max(...b.components.map((comp) => Date.parse(comp.provenance.createdAt || '1970-01-01T00:00:00Z')));
return bCreated - aCreated;
}
if (aPrompt !== bPrompt) return aPrompt ? -1 : 1;
return Math.min(...a.components.map((comp) => comp.layout.orderIndex)) - Math.min(...b.components.map((comp) => comp.layout.orderIndex));
});
}
function getSectionLabel(sectionId: string, sectionComps: CanvasComponent[]): string {
if (SECTION_LABELS[sectionId]) return SECTION_LABELS[sectionId];
if (sectionId.startsWith('sec_prompt_generated')) {
const planning = sectionComps.find((comp) => comp.type === 'textCanvas');
const content = planning?.visualizationParameters?.content;
if (typeof content === 'string') {
const firstLine = content.split('\n')[0]?.trim();
if (firstLine?.startsWith('Oracle received:')) {
return firstLine.replace('Oracle received:', '').trim();
}
}
return 'Oracle Response';
}
return sectionId.replace(/^sec_/, '').replace(/_/g, ' ');
}
/** CSS content-visibility wrapper for off-screen components, applying width mode to the flex item */
@@ -93,7 +121,7 @@ export function CanvasViewport({
<div className="flex items-center gap-3">
<div className="w-1 h-4 rounded-full bg-gradient-to-b from-blue-400 to-cyan-500" />
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500">
{SECTION_LABELS[sectionId] ?? sectionId.replace(/^sec_/, '').replace(/_/g, ' ')}
{getSectionLabel(sectionId, sectionComps)}
</h2>
<div className="flex-1 h-[1px]" style={{ background: 'rgba(255,255,255,0.05)' }} />
<span className="text-[10px] text-zinc-700">{sectionComps.length}</span>

View File

@@ -10,6 +10,7 @@ interface ShareModalProps {
page: CanvasPage | null;
isOpen: boolean;
onClose: () => void;
currentUserId?: string | null;
onShare: (params: {
recipientUserId: string;
visibility: 'private' | 'team';
@@ -40,7 +41,7 @@ function getInitials(member: VelocityActiveUser): string {
.join('') || 'U';
}
export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps) {
export function ShareModal({ page, isOpen, onClose, currentUserId, onShare }: ShareModalProps) {
const [mounted, setMounted] = useState(false);
const [teamMembers, setTeamMembers] = useState<VelocityActiveUser[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
@@ -50,6 +51,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
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), []);
@@ -57,6 +59,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
useEffect(() => {
if (!isOpen) {
setMemberDropOpen(false);
setSubmitError(null);
return;
}
@@ -83,6 +86,17 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
};
}, [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],
@@ -91,6 +105,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
const handleShare = async () => {
if (!recipient || !page) return;
setSubmitting(true);
setSubmitError(null);
try {
await onShare({
recipientUserId: recipient.user_id,
@@ -105,8 +120,8 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
setRecipient(null);
setMessage('');
}, 1800);
} catch {
// keep modal open and let caller surface the error upstream
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Share failed.');
} finally {
setSubmitting(false);
}
@@ -180,6 +195,17 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
</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">
@@ -217,10 +243,10 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
{!loadingMembers && membersError && (
<div className="px-3 py-3 text-xs text-red-400">{membersError}</div>
)}
{!loadingMembers && !membersError && teamMembers.length === 0 && (
{!loadingMembers && !membersError && availableMembers.length === 0 && (
<div className="px-3 py-3 text-xs text-zinc-500">No verified users available.</div>
)}
{!loadingMembers && !membersError && teamMembers.map((member) => (
{!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"