279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|