Fix button roundtrip in editor, alignment persistence, and test email rendering
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Plus, Monitor, Smartphone, LayoutTemplate } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import { CanvasSection } from './CanvasSection';
|
||||
import {
|
||||
HeroRenderer,
|
||||
ContentRenderer,
|
||||
ImageTextRenderer,
|
||||
FeatureGridRenderer,
|
||||
CTABannerRenderer,
|
||||
ContactFormRenderer,
|
||||
} from './section-renderers';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
interface CanvasRendererProps {
|
||||
sections: Section[];
|
||||
selectedSectionId: string | null;
|
||||
deviceMode: 'desktop' | 'mobile';
|
||||
onSelectSection: (id: string | null) => void;
|
||||
onAddSection: (type: string, index?: number) => void;
|
||||
onDeleteSection: (id: string) => void;
|
||||
onDuplicateSection: (id: string) => void;
|
||||
onMoveSection: (id: string, direction: 'up' | 'down') => void;
|
||||
onReorderSections: (sections: Section[]) => void;
|
||||
onDeviceModeChange: (mode: 'desktop' | 'mobile') => void;
|
||||
}
|
||||
|
||||
const SECTION_TYPES = [
|
||||
{ type: 'hero', label: 'Hero', icon: LayoutTemplate },
|
||||
{ type: 'content', label: 'Content', icon: LayoutTemplate },
|
||||
{ type: 'image-text', label: 'Image + Text', icon: LayoutTemplate },
|
||||
{ type: 'feature-grid', label: 'Feature Grid', icon: LayoutTemplate },
|
||||
{ type: 'cta-banner', label: 'CTA Banner', icon: LayoutTemplate },
|
||||
{ type: 'contact-form', label: 'Contact Form', icon: LayoutTemplate },
|
||||
];
|
||||
|
||||
// Map section type to renderer component
|
||||
const SECTION_RENDERERS: Record<string, React.FC<{ section: Section; className?: string }>> = {
|
||||
'hero': HeroRenderer,
|
||||
'content': ContentRenderer,
|
||||
'image-text': ImageTextRenderer,
|
||||
'feature-grid': FeatureGridRenderer,
|
||||
'cta-banner': CTABannerRenderer,
|
||||
'contact-form': ContactFormRenderer,
|
||||
};
|
||||
|
||||
export function CanvasRenderer({
|
||||
sections,
|
||||
selectedSectionId,
|
||||
deviceMode,
|
||||
onSelectSection,
|
||||
onAddSection,
|
||||
onDeleteSection,
|
||||
onDuplicateSection,
|
||||
onMoveSection,
|
||||
onReorderSections,
|
||||
onDeviceModeChange,
|
||||
}: CanvasRendererProps) {
|
||||
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = sections.findIndex(s => s.id === active.id);
|
||||
const newIndex = sections.findIndex(s => s.id === over.id);
|
||||
const newSections = arrayMove(sections, oldIndex, newIndex);
|
||||
onReorderSections(newSections);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasClick = (e: React.MouseEvent) => {
|
||||
// Only deselect if clicking directly on canvas background
|
||||
if (e.target === e.currentTarget) {
|
||||
onSelectSection(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col bg-gray-100 overflow-hidden">
|
||||
{/* Device mode toggle */}
|
||||
<div className="flex items-center justify-center gap-2 py-3 bg-white border-b">
|
||||
<Button
|
||||
variant={deviceMode === 'desktop' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onDeviceModeChange('desktop')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
Desktop
|
||||
</Button>
|
||||
<Button
|
||||
variant={deviceMode === 'mobile' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onDeviceModeChange('mobile')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Smartphone className="w-4 h-4" />
|
||||
Mobile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Canvas viewport */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-6"
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto bg-white shadow-xl rounded-lg transition-all duration-300 min-h-[500px]',
|
||||
deviceMode === 'desktop' ? 'max-w-4xl' : 'max-w-sm'
|
||||
)}
|
||||
>
|
||||
{sections.length === 0 ? (
|
||||
<div className="py-24 text-center text-gray-400">
|
||||
<LayoutTemplate className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">No sections yet</p>
|
||||
<p className="text-sm mb-6">Add your first section to start building</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Section
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{SECTION_TYPES.map((type) => (
|
||||
<DropdownMenuItem
|
||||
key={type.type}
|
||||
onClick={() => onAddSection(type.type)}
|
||||
>
|
||||
<type.icon className="w-4 h-4 mr-2" />
|
||||
{type.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{/* Top Insertion Zone */}
|
||||
<InsertionZone
|
||||
index={0}
|
||||
onAdd={(type) => onAddSection(type)} // Implicitly index 0 is fine if we handle it in store, but wait store expects index.
|
||||
// Actually onAddSection in Props is (type) => void. I need to update Props too.
|
||||
// Let's check props interface above.
|
||||
/>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sections.map(s => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{sections.map((section, index) => {
|
||||
const Renderer = SECTION_RENDERERS[section.type];
|
||||
|
||||
return (
|
||||
<React.Fragment key={section.id}>
|
||||
<CanvasSection
|
||||
section={section}
|
||||
isSelected={selectedSectionId === section.id}
|
||||
isHovered={hoveredSectionId === section.id}
|
||||
onSelect={() => onSelectSection(section.id)}
|
||||
onHover={() => setHoveredSectionId(section.id)}
|
||||
onLeave={() => setHoveredSectionId(null)}
|
||||
onDelete={() => onDeleteSection(section.id)}
|
||||
onDuplicate={() => onDuplicateSection(section.id)}
|
||||
onMoveUp={() => onMoveSection(section.id, 'up')}
|
||||
onMoveDown={() => onMoveSection(section.id, 'down')}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < sections.length - 1}
|
||||
>
|
||||
{Renderer ? (
|
||||
<Renderer section={section} />
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
Unknown section type: {section.type}
|
||||
</div>
|
||||
)}
|
||||
</CanvasSection>
|
||||
|
||||
{/* Insertion Zone After Section */}
|
||||
<InsertionZone
|
||||
index={index + 1}
|
||||
onAdd={(type) => onAddSection(type, index + 1)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: Insertion Zone Component
|
||||
function InsertionZone({ index, onAdd }: { index: number; onAdd: (type: string) => void }) {
|
||||
return (
|
||||
<div className="group relative h-4 -my-2 z-10 flex items-center justify-center transition-all hover:h-8 hover:my-0">
|
||||
{/* Line */}
|
||||
<div className="absolute left-4 right-4 h-0.5 bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
{/* Button */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="relative z-10 w-6 h-6 rounded-full bg-blue-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all hover:scale-110 shadow-sm"
|
||||
title="Add Section Here"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{SECTION_TYPES.map((type) => (
|
||||
<DropdownMenuItem
|
||||
key={type.type}
|
||||
onClick={() => onAdd(type.type)}
|
||||
>
|
||||
<type.icon className="w-4 h-4 mr-2" />
|
||||
{type.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user