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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GripVertical, Trash2, Copy, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Section } from '../store/usePageEditorStore';
|
||||
|
||||
interface CanvasSectionProps {
|
||||
section: Section;
|
||||
children: ReactNode;
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
onSelect: () => void;
|
||||
onHover: () => void;
|
||||
onLeave: () => void;
|
||||
onDelete: () => void;
|
||||
onDuplicate: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}
|
||||
|
||||
export function CanvasSection({
|
||||
section,
|
||||
children,
|
||||
isSelected,
|
||||
isHovered,
|
||||
onSelect,
|
||||
onHover,
|
||||
onLeave,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
}: CanvasSectionProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: section.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'relative group transition-all duration-200',
|
||||
isDragging && 'opacity-50 z-50',
|
||||
isSelected && 'ring-2 ring-blue-500 ring-offset-2',
|
||||
isHovered && !isSelected && 'ring-1 ring-blue-300'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
onMouseEnter={onHover}
|
||||
onMouseLeave={onLeave}
|
||||
>
|
||||
{/* Section content with Styles */}
|
||||
<div
|
||||
className={cn("relative overflow-hidden rounded-lg", !section.styles?.backgroundColor && "bg-white/50")}
|
||||
style={{
|
||||
backgroundColor: section.styles?.backgroundColor,
|
||||
paddingTop: section.styles?.paddingTop,
|
||||
paddingBottom: section.styles?.paddingBottom,
|
||||
}}
|
||||
>
|
||||
{/* Background Image & Overlay */}
|
||||
{section.styles?.backgroundImage && (
|
||||
<>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${section.styles.backgroundImage})` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-black"
|
||||
style={{ opacity: (section.styles.backgroundOverlay || 0) / 100 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content Wrapper */}
|
||||
<div className={cn(
|
||||
"relative z-10",
|
||||
section.styles?.contentWidth === 'contained' ? 'container mx-auto px-4 max-w-6xl' : 'w-full'
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Toolbar (Standard Interaction) */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-10 right-0 z-50 flex items-center gap-1 bg-white shadow-lg border rounded-lg px-2 py-1 animate-in fade-in slide-in-from-bottom-2">
|
||||
{/* Label */}
|
||||
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide mr-2 px-1">
|
||||
{section.type.replace('-', ' ')}
|
||||
</span>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-4 bg-gray-200 mx-1" />
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveUp();
|
||||
}}
|
||||
disabled={!canMoveUp}
|
||||
className={cn(
|
||||
'p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition',
|
||||
!canMoveUp && 'opacity-30 cursor-not-allowed'
|
||||
)}
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveDown();
|
||||
}}
|
||||
disabled={!canMoveDown}
|
||||
className={cn(
|
||||
'p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition',
|
||||
!canMoveDown && 'opacity-30 cursor-not-allowed'
|
||||
)}
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
className="p-1.5 rounded text-gray-500 hover:text-black hover:bg-gray-100 transition"
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-gray-200 mx-1" />
|
||||
<div className="w-px h-4 bg-gray-200 mx-1" />
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="z-[60]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
{__('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Border Label */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-px left-0 bg-blue-500 text-white text-[10px] uppercase font-bold px-2 py-0.5 rounded-b-sm z-10">
|
||||
{section.type}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag Handle (Always visible on hover or select) */}
|
||||
{(isSelected || isHovered) && (
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="absolute top-1/2 -left-8 -translate-y-1/2 p-1.5 rounded text-gray-400 hover:text-gray-600 cursor-grab active:cursor-grabbing hover:bg-gray-100"
|
||||
title="Drag to reorder"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -35,6 +35,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
const [pageType, setPageType] = useState<'page' | 'template'>('page');
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>('blank');
|
||||
|
||||
// Prevent double submission
|
||||
const isSubmittingRef = useRef(false);
|
||||
@@ -42,9 +43,18 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
// Get site URL from WordPress config
|
||||
const siteUrl = window.WNW_CONFIG?.siteUrl?.replace(/\/$/, '') || window.location.origin;
|
||||
|
||||
// Fetch templates
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ['templates-presets'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/templates/presets');
|
||||
return res as { id: string; label: string; description: string; icon: string }[];
|
||||
}
|
||||
});
|
||||
|
||||
// Create page mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: { title: string; slug: string }) => {
|
||||
mutationFn: async (data: { title: string; slug: string; templateId?: string }) => {
|
||||
// Guard against double submission
|
||||
if (isSubmittingRef.current) {
|
||||
throw new Error('Request already in progress');
|
||||
@@ -53,7 +63,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
|
||||
try {
|
||||
// api.post returns JSON directly (not wrapped in { data: ... })
|
||||
const response = await api.post('/pages', { title: data.title, slug: data.slug });
|
||||
const response = await api.post('/pages', {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
templateId: data.templateId
|
||||
});
|
||||
return response; // Return response directly, not response.data
|
||||
} finally {
|
||||
// Reset after a delay to prevent race conditions
|
||||
@@ -74,6 +88,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
onOpenChange(false);
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setSelectedTemplateId('blank');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -105,7 +120,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
return;
|
||||
}
|
||||
if (pageType === 'page' && title && slug) {
|
||||
createMutation.mutate({ title, slug });
|
||||
createMutation.mutate({ title, slug, templateId: selectedTemplateId });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,6 +130,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
setPageType('page');
|
||||
setSelectedTemplateId('blank');
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
}, [open]);
|
||||
@@ -123,7 +139,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Create New Page')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -133,8 +149,11 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
|
||||
<div className="space-y-6 py-4 px-1">
|
||||
{/* Page Type Selection */}
|
||||
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')}>
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer hover:bg-accent/50 transition-colors">
|
||||
<RadioGroup value={pageType} onValueChange={(v) => setPageType(v as 'page' | 'template')} className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className={`flex items-start space-x-3 p-4 border rounded-lg cursor-pointer transition-colors ${pageType === 'page' ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-accent/50'}`}
|
||||
onClick={() => setPageType('page')}
|
||||
>
|
||||
<RadioGroupItem value="page" id="page" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="page" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
@@ -147,7 +166,7 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50">
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer opacity-50 relative">
|
||||
<RadioGroupItem value="template" id="template" className="mt-1" disabled />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="template" className="flex items-center gap-2 cursor-pointer font-medium">
|
||||
@@ -163,30 +182,59 @@ export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageMod
|
||||
|
||||
{/* Page Details */}
|
||||
{pageType === 'page' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{__('Page Title')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder={__('e.g., About Us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{__('Page Title')}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder={__('e.g., About Us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">{__('URL Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder={__('e.g., about-us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono text-primary">{siteUrl}/{slug || 'page'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">{__('URL Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder={__('e.g., about-us')}
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('URL will be: ')}<span className="font-mono text-primary">{siteUrl}/{slug || 'page-slug'}</span>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Label>{__('Choose a Template')}</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`
|
||||
relative p-3 border rounded-lg cursor-pointer transition-all hover:bg-accent
|
||||
${selectedTemplateId === tpl.id ? 'border-primary ring-1 ring-primary bg-primary/5' : 'border-border'}
|
||||
`}
|
||||
onClick={() => setSelectedTemplateId(tpl.id)}
|
||||
>
|
||||
<div className="mb-2 font-medium text-sm flex items-center gap-2">
|
||||
{tpl.label}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{tpl.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<div className="col-span-4 text-center py-4 text-muted-foreground text-sm">
|
||||
{__('Loading templates...')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MediaUploader } from '@/components/MediaUploader';
|
||||
import { Image as ImageIcon } from 'lucide-react';
|
||||
|
||||
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||
|
||||
export interface SectionProp {
|
||||
type: 'static' | 'dynamic';
|
||||
value?: any;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface InspectorFieldProps {
|
||||
fieldName: string;
|
||||
fieldLabel: string;
|
||||
fieldType: 'text' | 'textarea' | 'url' | 'image' | 'rte';
|
||||
value: SectionProp;
|
||||
onChange: (value: SectionProp) => void;
|
||||
supportsDynamic?: boolean;
|
||||
availableSources?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export function InspectorField({
|
||||
fieldName,
|
||||
fieldLabel,
|
||||
fieldType,
|
||||
value,
|
||||
onChange,
|
||||
supportsDynamic = false,
|
||||
availableSources = [],
|
||||
}: InspectorFieldProps) {
|
||||
const isDynamic = value.type === 'dynamic';
|
||||
const currentValue = isDynamic ? (value.source || '') : (value.value || '');
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
if (isDynamic) {
|
||||
onChange({ type: 'dynamic', source: newValue });
|
||||
} else {
|
||||
onChange({ type: 'static', value: newValue });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeToggle = (dynamic: boolean) => {
|
||||
if (dynamic) {
|
||||
onChange({ type: 'dynamic', source: availableSources[0]?.value || 'post_title' });
|
||||
} else {
|
||||
onChange({ type: 'static', value: '' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||
{fieldLabel}
|
||||
</Label>
|
||||
{supportsDynamic && availableSources.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'text-xs',
|
||||
isDynamic ? 'text-orange-500 font-medium' : 'text-gray-400'
|
||||
)}>
|
||||
{isDynamic ? '◆ Dynamic' : 'Static'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isDynamic}
|
||||
onCheckedChange={handleTypeToggle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDynamic && supportsDynamic ? (
|
||||
<Select value={currentValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select data source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSources.map((source) => (
|
||||
<SelectItem key={source.value} value={source.value}>
|
||||
{source.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : fieldType === 'rte' ? (
|
||||
<RichTextEditor
|
||||
content={currentValue}
|
||||
onChange={handleValueChange}
|
||||
placeholder={`Enter ${fieldLabel.toLowerCase()}...`}
|
||||
/>
|
||||
) : fieldType === 'textarea' ? (
|
||||
<Textarea
|
||||
id={fieldName}
|
||||
value={currentValue}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={fieldName}
|
||||
type={fieldType === 'url' ? 'url' : 'text'}
|
||||
value={currentValue}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
placeholder={fieldType === 'url' ? 'https://' : `Enter ${fieldLabel.toLowerCase()}`}
|
||||
className="flex-1"
|
||||
/>
|
||||
{(fieldType === 'url' || fieldType === 'image') && (
|
||||
<MediaUploader
|
||||
onSelect={(url) => handleValueChange(url)}
|
||||
type="image"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Button variant="outline" size="icon" title={__('Select Image')}>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</MediaUploader>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
Settings,
|
||||
PanelRightClose,
|
||||
PanelRight,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Palette,
|
||||
Type,
|
||||
Home
|
||||
} from 'lucide-react';
|
||||
import { InspectorField, SectionProp } from './InspectorField';
|
||||
import { InspectorRepeater } from './InspectorRepeater';
|
||||
import { MediaUploader } from '@/components/MediaUploader';
|
||||
import { SectionStyles, ElementStyle } from '../store/usePageEditorStore';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
styles?: SectionStyles;
|
||||
elementStyles?: Record<string, ElementStyle>;
|
||||
props: Record<string, SectionProp>;
|
||||
}
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
isSpaLanding?: boolean;
|
||||
}
|
||||
|
||||
interface InspectorPanelProps {
|
||||
page: PageItem | null;
|
||||
selectedSection: Section | null;
|
||||
isCollapsed: boolean;
|
||||
isTemplate: boolean;
|
||||
availableSources: { value: string; label: string }[];
|
||||
onToggleCollapse: () => void;
|
||||
onSectionPropChange: (propName: string, value: SectionProp) => void;
|
||||
onLayoutChange: (layout: string) => void;
|
||||
onColorSchemeChange: (scheme: string) => void;
|
||||
onSectionStylesChange: (styles: Partial<SectionStyles>) => void;
|
||||
onElementStylesChange: (fieldName: string, styles: Partial<ElementStyle>) => void;
|
||||
onDeleteSection: () => void;
|
||||
onSetAsSpaLanding?: () => void;
|
||||
onUnsetSpaLanding?: () => void;
|
||||
onDeletePage?: () => void;
|
||||
}
|
||||
|
||||
// Section field configurations
|
||||
const SECTION_FIELDS: Record<string, { name: string; label: string; type: 'text' | 'textarea' | 'url' | 'image' | 'rte'; dynamic?: boolean }[]> = {
|
||||
hero: [
|
||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true },
|
||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
content: [
|
||||
{ name: 'content', label: 'Content', type: 'rte', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
'image-text': [
|
||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||
{ name: 'text', label: 'Text', type: 'textarea', dynamic: true },
|
||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||
],
|
||||
'cta-banner': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'text', label: 'Description', type: 'text' },
|
||||
{ name: 'button_text', label: 'Button Text', type: 'text' },
|
||||
{ name: 'button_url', label: 'Button URL', type: 'url' },
|
||||
],
|
||||
'contact-form': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'webhook_url', label: 'Webhook URL', type: 'url' },
|
||||
{ name: 'redirect_url', label: 'Redirect URL', type: 'url' },
|
||||
],
|
||||
};
|
||||
|
||||
const LAYOUT_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
||||
hero: [
|
||||
{ value: 'default', label: 'Centered' },
|
||||
{ value: 'hero-left-image', label: 'Image Left' },
|
||||
{ value: 'hero-right-image', label: 'Image Right' },
|
||||
],
|
||||
'image-text': [
|
||||
{ value: 'image-left', label: 'Image Left' },
|
||||
{ value: 'image-right', label: 'Image Right' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ value: 'grid-2', label: '2 Columns' },
|
||||
{ value: 'grid-3', label: '3 Columns' },
|
||||
{ value: 'grid-4', label: '4 Columns' },
|
||||
],
|
||||
content: [
|
||||
{ value: 'default', label: 'Full Width' },
|
||||
{ value: 'narrow', label: 'Narrow' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
],
|
||||
};
|
||||
|
||||
const COLOR_SCHEMES = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'muted', label: 'Muted' },
|
||||
{ value: 'gradient', label: 'Gradient' },
|
||||
];
|
||||
|
||||
const STYLABLE_ELEMENTS: Record<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
|
||||
hero: [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||
{ name: 'image', label: 'Image', type: 'image' },
|
||||
{ name: 'cta_text', label: 'Button', type: 'text' },
|
||||
],
|
||||
content: [
|
||||
{ name: 'heading', label: 'Headings', type: 'text' },
|
||||
{ name: 'text', label: 'Body Text', type: 'text' },
|
||||
{ name: 'link', label: 'Links', type: 'text' },
|
||||
{ name: 'image', label: 'Images', type: 'image' },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
{ name: 'content', label: 'Container', type: 'text' }, // Keep for backward compat or wrapper style
|
||||
],
|
||||
'image-text': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'text', label: 'Text', type: 'text' },
|
||||
{ name: 'image', label: 'Image', type: 'image' },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
|
||||
],
|
||||
'cta-banner': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'text', label: 'Description', type: 'text' },
|
||||
{ name: 'button_text', label: 'Button', type: 'text' },
|
||||
],
|
||||
'contact-form': [
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'button', label: 'Button', type: 'text' },
|
||||
{ name: 'fields', label: 'Input Fields', type: 'text' },
|
||||
],
|
||||
};
|
||||
|
||||
export function InspectorPanel({
|
||||
page,
|
||||
selectedSection,
|
||||
isCollapsed,
|
||||
isTemplate,
|
||||
availableSources,
|
||||
onToggleCollapse,
|
||||
onSectionPropChange,
|
||||
onLayoutChange,
|
||||
onColorSchemeChange,
|
||||
onSectionStylesChange,
|
||||
onElementStylesChange,
|
||||
onDeleteSection,
|
||||
onSetAsSpaLanding,
|
||||
onUnsetSpaLanding,
|
||||
onDeletePage,
|
||||
}: InspectorPanelProps) {
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="w-10 border-l bg-white flex flex-col items-center py-4">
|
||||
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="mb-4">
|
||||
<PanelRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedSection) {
|
||||
return (
|
||||
<div className="w-80 border-l bg-white flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-4 border-b shrink-0">
|
||||
<h3 className="font-semibold text-sm">{__('Page Settings')}</h3>
|
||||
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="h-8 w-8">
|
||||
<PanelRightClose className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<Settings className="w-4 h-4" />
|
||||
{isTemplate ? __('Template Info') : __('Page Info')}
|
||||
</div>
|
||||
<div className="space-y-4 bg-gray-50 p-3 rounded-lg border">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('Type')}</Label>
|
||||
<p className="text-sm font-medium">
|
||||
{isTemplate ? __('Template (Dynamic)') : __('Page (Structural)')}
|
||||
</p>
|
||||
</div>
|
||||
{page?.title && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('Title')}</Label>
|
||||
<p className="text-sm font-medium">{page.title}</p>
|
||||
</div>
|
||||
)}
|
||||
{page?.url && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 uppercase tracking-wider">{__('URL')}</Label>
|
||||
<a href={page.url} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-600 hover:underline flex items-center gap-1 mt-1">
|
||||
{__('View Page')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SPA Landing Settings - Only for Pages */}
|
||||
{!isTemplate && page && (
|
||||
<div className="pt-2 border-t mt-2">
|
||||
<Label className="text-xs text-gray-500 uppercase tracking-wider block mb-2">{__('SPA Landing Page')}</Label>
|
||||
{page.isSpaLanding ? (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-green-50 text-green-700 px-3 py-2 rounded-md text-sm flex items-center gap-2 border border-green-100">
|
||||
<Home className="w-4 h-4" />
|
||||
<span className="font-medium">{__('This is your SPA Landing')}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
|
||||
onClick={onUnsetSpaLanding}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{__('Unset Landing Page')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={onSetAsSpaLanding}
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
{__('Set as SPA Landing')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger Zone */}
|
||||
{!isTemplate && page && onDeletePage && (
|
||||
<div className="pt-2 border-t mt-2">
|
||||
<Label className="text-xs text-red-600 uppercase tracking-wider block mb-2">{__('Danger Zone')}</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
|
||||
onClick={onDeletePage}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{__('Delete This Page')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-blue-50 text-blue-800 p-3 rounded text-xs leading-relaxed">
|
||||
{__('Select any section on the canvas to edit its content and design.')}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-80 border-l bg-white flex flex-col transition-all duration-300 shadow-xl z-30",
|
||||
isCollapsed && "w-0 overflow-hidden border-none"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-14 border-b flex items-center justify-between px-4 shrink-0 bg-white">
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{SECTION_FIELDS[selectedSection.type]
|
||||
? (selectedSection.type.charAt(0).toUpperCase() + selectedSection.type.slice(1)).replace('-', ' ')
|
||||
: 'Settings'}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" onClick={onToggleCollapse} className="h-8 w-8 hover:bg-gray-100">
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content Tabs (Content vs Design) */}
|
||||
<Tabs defaultValue="content" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-4 pt-4 shrink-0 bg-white">
|
||||
<TabsList className="w-full grid grid-cols-2">
|
||||
<TabsTrigger value="content">{__('Content')}</TabsTrigger>
|
||||
<TabsTrigger value="design">{__('Design')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Content Tab */}
|
||||
<TabsContent value="content" className="p-4 space-y-6 m-0">
|
||||
{/* Structure & Presets */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Structure')}</h4>
|
||||
{LAYOUT_OPTIONS[selectedSection.type] && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Layout Variant')}</Label>
|
||||
<Select
|
||||
value={selectedSection.layoutVariant || 'default'}
|
||||
onValueChange={onLayoutChange}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{LAYOUT_OPTIONS[selectedSection.type].map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Preset Scheme')}</Label>
|
||||
<Select
|
||||
value={selectedSection.colorScheme || 'default'}
|
||||
onValueChange={onColorSchemeChange}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_SCHEMES.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-gray-100" />
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Fields')}</h4>
|
||||
{SECTION_FIELDS[selectedSection.type]?.map((field) => (
|
||||
<React.Fragment key={field.name}>
|
||||
<InspectorField
|
||||
fieldName={field.name}
|
||||
fieldLabel={field.label}
|
||||
fieldType={field.type}
|
||||
value={selectedSection.props[field.name] || { type: 'static', value: '' }}
|
||||
onChange={(val) => onSectionPropChange(field.name, val)}
|
||||
supportsDynamic={field.dynamic && isTemplate}
|
||||
availableSources={availableSources}
|
||||
/>
|
||||
{selectedSection.type === 'contact-form' && field.name === 'redirect_url' && (
|
||||
<p className="text-[10px] text-gray-500 mt-1 pl-1">
|
||||
Available shortcodes: {'{name}'}, {'{email}'}, {'{date}'}
|
||||
</p>
|
||||
)}
|
||||
{selectedSection.type === 'contact-form' && field.name === 'webhook_url' && (
|
||||
<Accordion type="single" collapsible className="w-full mt-1 border rounded-md">
|
||||
<AccordionItem value="payload" className="border-0">
|
||||
<AccordionTrigger className="text-[10px] py-1 px-2 hover:no-underline text-gray-500">
|
||||
View Payload Example
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-2">
|
||||
<pre className="text-[10px] bg-gray-50 p-2 rounded overflow-x-auto">
|
||||
{JSON.stringify({
|
||||
"form_id": "contact_form_123",
|
||||
"fields": {
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"message": "Hello world"
|
||||
},
|
||||
"meta": {
|
||||
"url": "https://site.com/contact",
|
||||
"timestamp": 1710000000
|
||||
}
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feature Grid Repeater */}
|
||||
{selectedSection.type === 'feature-grid' && (
|
||||
<div className="pt-4 border-t">
|
||||
<InspectorRepeater
|
||||
label={__('Features')}
|
||||
items={Array.isArray(selectedSection.props.features?.value) ? selectedSection.props.features.value : []}
|
||||
onChange={(items) => onSectionPropChange('features', { type: 'static', value: items })}
|
||||
fields={[
|
||||
{ name: 'title', label: 'Title', type: 'text' },
|
||||
{ name: 'description', label: 'Description', type: 'textarea' },
|
||||
{ name: 'icon', label: 'Icon', type: 'icon' },
|
||||
]}
|
||||
itemLabelKey="title"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Design Tab */}
|
||||
<TabsContent value="design" className="p-4 space-y-6 m-0">
|
||||
{/* Background */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">{__('Background')}</h4>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Color')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative w-8 h-8 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: selectedSection.styles?.backgroundColor || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={selectedSection.styles?.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="#FFFFFF"
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
value={selectedSection.styles?.backgroundColor || ''}
|
||||
onChange={(e) => onSectionStylesChange({ backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">{__('Background Image')}</Label>
|
||||
<MediaUploader type="image" onSelect={(url) => onSectionStylesChange({ backgroundImage: url })}>
|
||||
{selectedSection.styles?.backgroundImage ? (
|
||||
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50">
|
||||
<img src={selectedSection.styles.backgroundImage} alt="Background" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-white text-xs font-medium">{__('Change')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSectionStylesChange({ backgroundImage: '' }); }}
|
||||
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal">
|
||||
<Palette className="w-6 h-6" />
|
||||
{__('Select Image')}
|
||||
</Button>
|
||||
)}
|
||||
</MediaUploader>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">{__('Overlay Opacity')}</Label>
|
||||
<span className="text-xs text-gray-500">{selectedSection.styles?.backgroundOverlay ?? 0}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[selectedSection.styles?.backgroundOverlay ?? 0]}
|
||||
max={100}
|
||||
step={5}
|
||||
onValueChange={(vals) => onSectionStylesChange({ backgroundOverlay: vals[0] })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-xs">{__('Section Height')}</Label>
|
||||
<Select
|
||||
value={selectedSection.styles?.heightPreset || 'default'}
|
||||
onValueChange={(val) => {
|
||||
// Map presets to padding values
|
||||
const paddingMap: Record<string, string> = {
|
||||
'default': '0',
|
||||
'small': '0',
|
||||
'medium': '0',
|
||||
'large': '0',
|
||||
'screen': '0',
|
||||
};
|
||||
const padding = paddingMap[val] || '4rem';
|
||||
|
||||
// If screen, we might need a specific flag, but for now lets reuse paddingTop/Bottom or add a new prop.
|
||||
// To avoid breaking schema, let's use paddingTop as the "preset carrier" or add a new styles prop if possible.
|
||||
// Since styles key is SectionStyles, let's stick to modifying paddingTop/Bottom for now as a simple preset.
|
||||
|
||||
onSectionStylesChange({
|
||||
paddingTop: padding,
|
||||
paddingBottom: padding,
|
||||
heightPreset: val // We'll add this to interface
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Height" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="small">Small (Compact)</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="large">Large (Spacious)</SelectItem>
|
||||
<SelectItem value="screen">Full Screen</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="w-full h-px bg-gray-100" />
|
||||
|
||||
{/* Element Styles */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{__('Element Styles')}</h4>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{(STYLABLE_ELEMENTS[selectedSection.type] || []).map((field) => {
|
||||
const styles = selectedSection.elementStyles?.[field.name] || {};
|
||||
const isImage = field.type === 'image';
|
||||
|
||||
return (
|
||||
<AccordionItem key={field.name} value={field.name}>
|
||||
<AccordionTrigger className="text-xs hover:no-underline py-2">{field.label}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-2">
|
||||
{/* Common: Background Wrapper */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Background (Wrapper)')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: styles.backgroundColor || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={styles.backgroundColor || '#ffffff'}
|
||||
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Color (#fff)"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
value={styles.backgroundColor || ''}
|
||||
onChange={(e) => onElementStylesChange(field.name, { backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isImage ? (
|
||||
<>
|
||||
{/* Text Color */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Text Color')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: styles.color || '#000000' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={styles.color || '#000000'}
|
||||
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Color (#000)"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
value={styles.color || ''}
|
||||
onChange={(e) => onElementStylesChange(field.name, { color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Typography Group */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Typography')}</Label>
|
||||
|
||||
<Select value={styles.fontFamily || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontFamily: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Font Family" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="primary">Primary (Headings)</SelectItem>
|
||||
<SelectItem value="secondary">Secondary (Body)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Select value={styles.fontSize || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontSize: val === 'default' ? undefined : val })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Size" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default Size</SelectItem>
|
||||
<SelectItem value="text-sm">Small</SelectItem>
|
||||
<SelectItem value="text-base">Base</SelectItem>
|
||||
<SelectItem value="text-lg">Large</SelectItem>
|
||||
<SelectItem value="text-xl">XL</SelectItem>
|
||||
<SelectItem value="text-2xl">2XL</SelectItem>
|
||||
<SelectItem value="text-3xl">3XL</SelectItem>
|
||||
<SelectItem value="text-4xl">4XL</SelectItem>
|
||||
<SelectItem value="text-5xl">5XL</SelectItem>
|
||||
<SelectItem value="text-6xl">6XL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={styles.fontWeight || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { fontWeight: val === 'default' ? undefined : val })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Weight" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default Weight</SelectItem>
|
||||
<SelectItem value="font-light">Light</SelectItem>
|
||||
<SelectItem value="font-normal">Normal</SelectItem>
|
||||
<SelectItem value="font-medium">Medium</SelectItem>
|
||||
<SelectItem value="font-semibold">Semibold</SelectItem>
|
||||
<SelectItem value="font-bold">Bold</SelectItem>
|
||||
<SelectItem value="font-extrabold">Extra Bold</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Select value={styles.textAlign || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textAlign: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Alignment" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default Align</SelectItem>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Link Specific Styles */}
|
||||
{field.name === 'link' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Link Styles')}</Label>
|
||||
<Select value={styles.textDecoration || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { textDecoration: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Decoration" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="underline">Underline</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Hover Color')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: styles.hoverColor || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={styles.hoverColor || '#000000'}
|
||||
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Hover Color"
|
||||
className="flex-1 h-7 text-xs rounded border px-2"
|
||||
value={styles.hoverColor || ''}
|
||||
onChange={(e) => onElementStylesChange(field.name, { hoverColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button/Box Specific Styles */}
|
||||
{field.name === 'button' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Box Styles')}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Border Color')}</Label>
|
||||
<div className="flex items-center gap-2 h-7">
|
||||
<div className="relative w-6 h-6 rounded border shadow-sm shrink-0 overflow-hidden">
|
||||
<div className="absolute inset-0" style={{ backgroundColor: styles.borderColor || 'transparent' }} />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full p-0 border-0"
|
||||
value={styles.borderColor || '#000000'}
|
||||
onChange={(e) => onElementStylesChange(field.name, { borderColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Border Width')}</Label>
|
||||
<input type="text" placeholder="e.g. 1px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderWidth || ''} onChange={(e) => onElementStylesChange(field.name, { borderWidth: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Radius')}</Label>
|
||||
<input type="text" placeholder="e.g. 4px" className="w-full h-7 text-xs rounded border px-2" value={styles.borderRadius || ''} onChange={(e) => onElementStylesChange(field.name, { borderRadius: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-400">{__('Padding')}</Label>
|
||||
<input type="text" placeholder="e.g. 8px 16px" className="w-full h-7 text-xs rounded border px-2" value={styles.padding || ''} onChange={(e) => onElementStylesChange(field.name, { padding: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Image Settings */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-gray-500">{__('Image Fit')}</Label>
|
||||
<Select value={styles.objectFit || 'default'} onValueChange={(val) => onElementStylesChange(field.name, { objectFit: val === 'default' ? undefined : val as any })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="Object Fit" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="cover">Cover</SelectItem>
|
||||
<SelectItem value="contain">Contain</SelectItem>
|
||||
<SelectItem value="fill">Fill</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Width')}</Label>
|
||||
<input type="text" placeholder="e.g. 100%" className="w-full h-7 text-xs rounded border px-2" value={styles.width || ''} onChange={(e) => onElementStylesChange(field.name, { width: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">{__('Height')}</Label>
|
||||
<input type="text" placeholder="e.g. auto" className="w-full h-7 text-xs rounded border px-2" value={styles.height || ''} onChange={(e) => onElementStylesChange(field.name, { height: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{/* Footer - Delete Button */}
|
||||
{
|
||||
selectedSection && (
|
||||
<div className="p-4 border-t mt-auto shrink-0 bg-gray-50/50">
|
||||
<Button variant="destructive" className="w-full" onClick={onDeleteSection}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{__('Delete Section')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Tabs >
|
||||
</div >
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Trash2, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
interface RepeaterFieldDef {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'textarea' | 'url' | 'image' | 'icon';
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface InspectorRepeaterProps {
|
||||
label: string;
|
||||
items: any[];
|
||||
fields: RepeaterFieldDef[];
|
||||
onChange: (items: any[]) => void;
|
||||
itemLabelKey?: string; // Key to use for the accordion header (e.g., 'title')
|
||||
}
|
||||
|
||||
// Sortable Item Component
|
||||
function SortableItem({ id, item, index, fields, itemLabelKey, onChange, onDelete }: any) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
// List of available icons for selection
|
||||
const ICON_OPTIONS = [
|
||||
'Star', 'Zap', 'Shield', 'Heart', 'Award', 'Clock', 'User', 'Settings',
|
||||
'Check', 'X', 'ArrowRight', 'Mail', 'Phone', 'MapPin', 'Briefcase',
|
||||
'Calendar', 'Camera', 'Cloud', 'Code', 'Cpu', 'CreditCard', 'Database',
|
||||
'DollarSign', 'Eye', 'File', 'Folder', 'Globe', 'Home', 'Image',
|
||||
'Layers', 'Layout', 'LifeBuoy', 'Link', 'Lock', 'MessageCircle',
|
||||
'Monitor', 'Moon', 'Music', 'Package', 'PieChart', 'Play', 'Power',
|
||||
'Printer', 'Radio', 'Search', 'Server', 'ShoppingBag', 'ShoppingCart',
|
||||
'Smartphone', 'Speaker', 'Sun', 'Tablet', 'Tag', 'Terminal', 'Tool',
|
||||
'Truck', 'Tv', 'Umbrella', 'Upload', 'Video', 'Voicemail', 'Volume2',
|
||||
'Wifi', 'Wrench'
|
||||
].sort();
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="bg-white border rounded-md mb-2">
|
||||
<AccordionItem value={`item-${index}`} className="border-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b bg-gray-50/50 rounded-t-md">
|
||||
<button {...attributes} {...listeners} className="cursor-grab text-gray-400 hover:text-gray-600">
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
<AccordionTrigger className="hover:no-underline py-0 flex-1 text-sm font-medium">
|
||||
{item[itemLabelKey] || `Item ${index + 1}`}
|
||||
</AccordionTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-gray-400 hover:text-red-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(index);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<AccordionContent className="p-3 space-y-3">
|
||||
{fields.map((field: RepeaterFieldDef) => (
|
||||
<div key={field.name} className="space-y-1.5">
|
||||
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||
{field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
value={item[field.name] || ''}
|
||||
onChange={(e) => onChange(index, field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="text-xs min-h-[60px]"
|
||||
/>
|
||||
) : field.type === 'icon' ? (
|
||||
<Select
|
||||
value={item[field.name] || ''}
|
||||
onValueChange={(val) => onChange(index, field.name, val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-full">
|
||||
<SelectValue placeholder="Select an icon" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[200px]">
|
||||
{ICON_OPTIONS.map(iconName => (
|
||||
<SelectItem key={iconName} value={iconName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{iconName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={item[field.name] || ''}
|
||||
onChange={(e) => onChange(index, field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title' }: InspectorRepeaterProps) {
|
||||
// Generate simple stable IDs for sorting if items don't have them
|
||||
const itemIds = items.map((_, i) => `item-${i}`);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = itemIds.indexOf(active.id as string);
|
||||
const newIndex = itemIds.indexOf(over.id as string);
|
||||
onChange(arrayMove(items, oldIndex, newIndex));
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemChange = (index: number, fieldName: string, value: string) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = { ...newItems[index], [fieldName]: value };
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
const newItem: any = {};
|
||||
fields.forEach(f => newItem[f.name] = '');
|
||||
onChange([...items, newItem]);
|
||||
};
|
||||
|
||||
const handleDeleteItem = (index: number) => {
|
||||
const newItems = [...items];
|
||||
newItems.splice(index, 1);
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={itemIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<SortableItem
|
||||
key={`item-${index}`} // Note: In a real app with IDs, use item.id
|
||||
id={`item-${index}`}
|
||||
index={index}
|
||||
item={item}
|
||||
fields={fields}
|
||||
itemLabelKey={itemLabelKey}
|
||||
onChange={handleItemChange}
|
||||
onDelete={handleDeleteItem}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Accordion>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-xs text-gray-400 text-center py-4 border border-dashed rounded-md bg-gray-50">
|
||||
No items yet. Click "Add Item" to start.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FileText, Layout, Loader2 } from 'lucide-react';
|
||||
import { FileText, Layout, Loader2, Home } from 'lucide-react';
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
@@ -51,14 +51,19 @@ export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: Pa
|
||||
key={`page-${page.id}`}
|
||||
onClick={() => onSelectPage(page)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between group',
|
||||
'hover:bg-gray-100',
|
||||
selectedPage?.id === page.id && selectedPage?.type === 'page'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
{page.title}
|
||||
<span className="truncate">{page.title}</span>
|
||||
{(page as any).isSpaLanding && (
|
||||
<span title="SPA Landing Page" className="flex-shrink-0 ml-2">
|
||||
<Home className="w-3.5 h-3.5 text-green-600" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,17 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
@@ -158,18 +169,36 @@ function SortableSectionCard({
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
if (confirm(__('Delete this section?'))) {
|
||||
onDelete();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
className="p-1.5 rounded text-red-500 hover:text-red-600 hover:bg-red-50 transition"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="z-[60]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{__('Delete this section?')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__('This action cannot be undone.')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{__('Cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
{__('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface CTABannerRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string; btnBg: string; btnText: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900', btnBg: 'bg-blue-600', btnText: 'text-white' },
|
||||
primary: { bg: 'bg-blue-600', text: 'text-white', btnBg: 'bg-white', btnText: 'text-blue-600' },
|
||||
secondary: { bg: 'bg-gray-800', text: 'text-white', btnBg: 'bg-white', btnText: 'text-gray-800' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', btnBg: 'bg-gray-800', btnText: 'text-white' },
|
||||
gradient: { bg: 'bg-gradient-to-r from-purple-600 to-blue-500', text: 'text-white', btnBg: 'bg-white', btnText: 'text-purple-600' },
|
||||
};
|
||||
|
||||
export function CTABannerRenderer({ section, className }: CTABannerRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'primary'];
|
||||
|
||||
const title = section.props?.title?.value || 'Ready to get started?';
|
||||
const text = section.props?.text?.value || 'Join thousands of happy customers today.';
|
||||
const buttonText = section.props?.button_text?.value || 'Get Started';
|
||||
const buttonUrl = section.props?.button_url?.value || '#';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const textStyle = getTextStyles('text');
|
||||
const btnStyle = getTextStyles('button_text');
|
||||
|
||||
return (
|
||||
<div className={cn('py-12 px-4 md:py-20 md:px-8', scheme.bg, scheme.text, className)}>
|
||||
<div className="max-w-4xl mx-auto text-center space-y-6">
|
||||
<h2
|
||||
className={cn(
|
||||
!titleStyle.classNames && "text-3xl md:text-4xl font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"max-w-2xl mx-auto",
|
||||
!textStyle.classNames && "text-lg opacity-90",
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
<button className={cn(
|
||||
'inline-flex items-center gap-2 px-8 py-4 rounded-lg font-semibold transition hover:opacity-90',
|
||||
!btnStyle.style?.backgroundColor && scheme.btnBg,
|
||||
!btnStyle.style?.color && scheme.btnText,
|
||||
btnStyle.classNames
|
||||
)}
|
||||
style={btnStyle.style}
|
||||
>
|
||||
{buttonText}
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Send, Mail, User, MessageSquare } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ContactFormRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string; inputBg: string; btnBg: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900', inputBg: 'bg-gray-50', btnBg: 'bg-blue-600' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white', inputBg: 'bg-white', btnBg: 'bg-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white', inputBg: 'bg-gray-700', btnBg: 'bg-blue-500' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', inputBg: 'bg-white', btnBg: 'bg-gray-800' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white', inputBg: 'bg-white/90', btnBg: 'bg-white' },
|
||||
};
|
||||
|
||||
export function ContactFormRenderer({ section, className }: ContactFormRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
|
||||
const title = section.props?.title?.value || 'Contact Us';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const buttonStyleObj = getTextStyles('button');
|
||||
const fieldsStyleObj = getTextStyles('fields');
|
||||
|
||||
const buttonStyle = section.elementStyles?.button || {};
|
||||
const fieldsStyle = section.elementStyles?.fields || {};
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-xl mx-auto">
|
||||
<h2
|
||||
className={cn(
|
||||
"text-3xl font-bold text-center mb-8",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
|
||||
{/* Name field */}
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Your Name"
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.backgroundColor,
|
||||
color: fieldsStyle.color
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email field */}
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your Email"
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.backgroundColor,
|
||||
color: fieldsStyle.color
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message field */}
|
||||
<div className="relative">
|
||||
<MessageSquare className="absolute left-4 top-4 w-5 h-5 text-gray-400" />
|
||||
<textarea
|
||||
placeholder="Your Message"
|
||||
rows={4}
|
||||
className={cn(
|
||||
'w-full pl-12 pr-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none',
|
||||
!fieldsStyle.backgroundColor && scheme.inputBg
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: fieldsStyle.backgroundColor,
|
||||
color: fieldsStyle.color
|
||||
}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition opacity-80 cursor-not-allowed',
|
||||
!buttonStyle.backgroundColor && scheme.btnBg,
|
||||
!buttonStyle.color && (section.colorScheme === 'primary' || section.colorScheme === 'gradient' ? 'text-blue-600' : 'text-white'),
|
||||
buttonStyleObj.classNames
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: buttonStyle.backgroundColor,
|
||||
color: buttonStyle.color
|
||||
}}
|
||||
disabled
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
Send Message
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm opacity-60">
|
||||
(Form preview only - functional on frontend)
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Section } from '../../store/usePageEditorStore';
|
||||
|
||||
interface ContentRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
default: { bg: 'bg-white', text: 'text-gray-900' },
|
||||
light: { bg: 'bg-gray-50', text: 'text-gray-900' },
|
||||
dark: { bg: 'bg-gray-900', text: 'text-white' },
|
||||
blue: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
const WIDTH_CLASSES: Record<string, string> = {
|
||||
default: 'max-w-6xl',
|
||||
narrow: 'max-w-2xl',
|
||||
medium: 'max-w-4xl',
|
||||
};
|
||||
|
||||
const fontSizeToCSS = (className?: string) => {
|
||||
switch (className) {
|
||||
case 'text-sm': return '0.875rem';
|
||||
case 'text-base': return '1rem';
|
||||
case 'text-lg': return '1.125rem';
|
||||
case 'text-xl': return '1.25rem';
|
||||
case 'text-2xl': return '1.5rem';
|
||||
case 'text-3xl': return '1.875rem';
|
||||
case 'text-4xl': return '2.25rem';
|
||||
case 'text-5xl': return '3rem';
|
||||
case 'text-6xl': return '3.75rem';
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const fontWeightToCSS = (className?: string) => {
|
||||
switch (className) {
|
||||
case 'font-light': return '300';
|
||||
case 'font-normal': return '400';
|
||||
case 'font-medium': return '500';
|
||||
case 'font-semibold': return '600';
|
||||
case 'font-bold': return '700';
|
||||
case 'font-extrabold': return '800';
|
||||
default: return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to generate scoped CSS for prose elements
|
||||
const generateScopedStyles = (sectionId: string, elementStyles: Record<string, any>) => {
|
||||
const styles: string[] = [];
|
||||
const scope = `#section-${sectionId}`;
|
||||
|
||||
// Headings (h1-h4)
|
||||
const hs = elementStyles?.heading;
|
||||
if (hs) {
|
||||
const headingRules = [
|
||||
hs.color && `color: ${hs.color} !important;`,
|
||||
hs.fontWeight && `font-weight: ${fontWeightToCSS(hs.fontWeight)} !important;`,
|
||||
hs.fontFamily && `font-family: var(--font-${hs.fontFamily}, inherit) !important;`,
|
||||
hs.fontSize && `font-size: ${fontSizeToCSS(hs.fontSize)} !important;`,
|
||||
hs.backgroundColor && `background-color: ${hs.backgroundColor} !important;`,
|
||||
// Add padding if background color is set to make it look decent
|
||||
hs.backgroundColor && `padding: 0.2em 0.4em; border-radius: 4px; display: inline-block;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (headingRules) {
|
||||
styles.push(`${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { ${headingRules} }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Body text (p, li)
|
||||
const ts = elementStyles?.text;
|
||||
if (ts) {
|
||||
const textRules = [
|
||||
ts.color && `color: ${ts.color} !important;`,
|
||||
ts.fontSize && `font-size: ${fontSizeToCSS(ts.fontSize)} !important;`,
|
||||
ts.fontWeight && `font-weight: ${fontWeightToCSS(ts.fontWeight)} !important;`,
|
||||
ts.fontFamily && `font-family: var(--font-${ts.fontFamily}, inherit) !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (textRules) {
|
||||
styles.push(`${scope} p, ${scope} li, ${scope} ul, ${scope} ol { ${textRules} }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit Spacing & List Formatting Restorations
|
||||
// These ensure vertical rhythm and list styles exist even if prose defaults are overridden or missing
|
||||
styles.push(`
|
||||
${scope} p { margin-bottom: 1em; }
|
||||
${scope} h1, ${scope} h2, ${scope} h3, ${scope} h4 { margin-top: 1.5em; margin-bottom: 0.5em; line-height: 1.2; }
|
||||
${scope} ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 1em; }
|
||||
${scope} ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 1em; }
|
||||
${scope} li { margin-bottom: 0.25em; }
|
||||
${scope} img { margin-top: 1.5em; margin-bottom: 1.5em; }
|
||||
`);
|
||||
|
||||
// Links (a:not(.button))
|
||||
const ls = elementStyles?.link;
|
||||
if (ls) {
|
||||
const linkRules = [
|
||||
ls.color && `color: ${ls.color} !important;`,
|
||||
ls.textDecoration && `text-decoration: ${ls.textDecoration} !important;`,
|
||||
ls.fontSize && `font-size: ${fontSizeToCSS(ls.fontSize)} !important;`,
|
||||
ls.fontWeight && `font-weight: ${fontWeightToCSS(ls.fontWeight)} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (linkRules) {
|
||||
styles.push(`${scope} a:not([data-button]):not(.button) { ${linkRules} }`);
|
||||
}
|
||||
if (ls.hoverColor) {
|
||||
styles.push(`${scope} a:not([data-button]):not(.button):hover { color: ${ls.hoverColor} !important; }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons (a[data-button], .button)
|
||||
const bs = elementStyles?.button;
|
||||
if (bs) {
|
||||
const btnRules = [
|
||||
bs.backgroundColor && `background-color: ${bs.backgroundColor} !important;`,
|
||||
bs.color && `color: ${bs.color} !important;`,
|
||||
bs.borderRadius && `border-radius: ${bs.borderRadius} !important;`,
|
||||
bs.padding && `padding: ${bs.padding} !important;`,
|
||||
bs.fontSize && `font-size: ${fontSizeToCSS(bs.fontSize)} !important;`,
|
||||
bs.fontWeight && `font-weight: ${fontWeightToCSS(bs.fontWeight)} !important;`,
|
||||
bs.borderColor && `border: ${bs.borderWidth || '1px'} solid ${bs.borderColor} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// Always force text-decoration: none for buttons
|
||||
styles.push(`${scope} a[data-button], ${scope} .button { ${btnRules} display: inline-block; text-decoration: none !important; }`);
|
||||
// Add hover effect opacity or something to make it feel alive, or just keep it simple
|
||||
styles.push(`${scope} a[data-button]:hover, ${scope} .button:hover { opacity: 0.9; }`);
|
||||
}
|
||||
|
||||
// Images
|
||||
const is = elementStyles?.image;
|
||||
if (is) {
|
||||
const imgRules = [
|
||||
is.objectFit && `object-fit: ${is.objectFit} !important;`,
|
||||
is.borderRadius && `border-radius: ${is.borderRadius} !important;`,
|
||||
is.width && `width: ${is.width} !important;`,
|
||||
is.height && `height: ${is.height} !important;`,
|
||||
].filter(Boolean).join(' ');
|
||||
if (imgRules) {
|
||||
styles.push(`${scope} img { ${imgRules} }`);
|
||||
}
|
||||
}
|
||||
|
||||
return styles.join('\n');
|
||||
};
|
||||
|
||||
export function ContentRenderer({ section, className }: ContentRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const layout = section.layoutVariant || 'default';
|
||||
const widthClass = WIDTH_CLASSES[layout] || WIDTH_CLASSES.default;
|
||||
|
||||
const heightPreset = section.styles?.heightPreset || 'default';
|
||||
|
||||
const heightMap: Record<string, string> = {
|
||||
'default': 'py-12 md:py-20',
|
||||
'small': 'py-8 md:py-12',
|
||||
'medium': 'py-16 md:py-24',
|
||||
'large': 'py-24 md:py-32',
|
||||
'screen': 'min-h-screen py-20 flex items-center',
|
||||
};
|
||||
|
||||
const heightClasses = heightMap[heightPreset] || 'py-12 md:py-20';
|
||||
|
||||
const content = section.props?.content?.value || 'Your content goes here. Edit this in the inspector panel.';
|
||||
const isDynamic = section.props?.content?.type === 'dynamic';
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const contentStyle = getTextStyles('content');
|
||||
const headingStyle = getTextStyles('heading');
|
||||
const buttonStyle = getTextStyles('button');
|
||||
const cta_text = section.props?.cta_text?.value;
|
||||
const cta_url = section.props?.cta_url?.value;
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`section-${section.id}`}
|
||||
className={cn(
|
||||
'px-4 md:px-8',
|
||||
heightClasses,
|
||||
!scheme.bg.startsWith('wn-') && scheme.bg,
|
||||
scheme.text,
|
||||
className
|
||||
)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<style dangerouslySetInnerHTML={{ __html: generateScopedStyles(section.id, section.elementStyles || {}) }} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto prose prose-lg max-w-none',
|
||||
// Default prose overrides
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-a:no-underline hover:prose-a:underline', // Establish baseline for links
|
||||
widthClass,
|
||||
scheme.text === 'text-white' && 'prose-invert',
|
||||
contentStyle.classNames // Apply font family, size, weight to container just in case
|
||||
)}
|
||||
style={{
|
||||
color: contentStyle.style.color,
|
||||
textAlign: contentStyle.style.textAlign as React.CSSProperties['textAlign'],
|
||||
'--tw-prose-headings': headingStyle.style?.color,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{isDynamic && (
|
||||
<div className="flex items-center gap-2 text-orange-400 text-sm font-medium mb-4">
|
||||
<span>◆</span>
|
||||
<span>{section.props?.content?.source || 'Dynamic Content'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div dangerouslySetInnerHTML={{ __html: content }} />
|
||||
|
||||
{cta_text && cta_url && (
|
||||
<div className="mt-8">
|
||||
<a
|
||||
href={cta_url}
|
||||
className={cn(
|
||||
"button inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2",
|
||||
!buttonStyle.style?.backgroundColor && "bg-blue-600",
|
||||
!buttonStyle.style?.color && "text-white",
|
||||
buttonStyle.classNames
|
||||
)}
|
||||
style={buttonStyle.style}
|
||||
>
|
||||
{cta_text}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface FeatureGridRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string; cardBg: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900', cardBg: 'bg-gray-50' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700', cardBg: 'bg-white' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white', cardBg: 'bg-white/10' },
|
||||
};
|
||||
|
||||
const GRID_CLASSES: Record<string, string> = {
|
||||
'grid-2': 'grid-cols-1 md:grid-cols-2',
|
||||
'grid-3': 'grid-cols-1 md:grid-cols-3',
|
||||
'grid-4': 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// Default features for demo
|
||||
const DEFAULT_FEATURES = [
|
||||
{ title: 'Fast Delivery', description: 'Quick shipping to your doorstep', icon: 'Truck' },
|
||||
{ title: 'Secure Payment', description: 'Your data is always protected', icon: 'Shield' },
|
||||
{ title: 'Quality Products', description: 'Only the best for our customers', icon: 'Star' },
|
||||
];
|
||||
|
||||
export function FeatureGridRenderer({ section, className }: FeatureGridRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const layout = section.layoutVariant || 'grid-3';
|
||||
const gridClass = GRID_CLASSES[layout] || GRID_CLASSES['grid-3'];
|
||||
|
||||
const heading = section.props?.heading?.value || 'Our Features';
|
||||
const features = section.props?.features?.value || DEFAULT_FEATURES;
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const headingStyle = getTextStyles('heading');
|
||||
const featureItemStyle = getTextStyles('feature_item');
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{heading && (
|
||||
<h2
|
||||
className={cn(
|
||||
"text-3xl md:text-4xl font-bold text-center mb-12",
|
||||
headingStyle.classNames
|
||||
)}
|
||||
style={headingStyle.style}
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
<div className={cn('grid gap-8', gridClass)}>
|
||||
{(Array.isArray(features) ? features : DEFAULT_FEATURES).map((feature: any, index: number) => {
|
||||
// Resolve icon from name, fallback to Star
|
||||
const IconComponent = (LucideIcons as any)[feature.icon] || LucideIcons.Star;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'p-6 rounded-xl text-center',
|
||||
!featureItemStyle.style?.backgroundColor && scheme.cardBg,
|
||||
featureItemStyle.classNames
|
||||
)}
|
||||
style={featureItemStyle.style}
|
||||
>
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<IconComponent className="w-7 h-7 text-blue-600" />
|
||||
</div>
|
||||
<h3
|
||||
className={cn(
|
||||
"mb-2",
|
||||
!featureItemStyle.style?.color && "text-lg font-semibold"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{feature.title || `Feature ${index + 1}`}
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!featureItemStyle.style?.color && "opacity-80"
|
||||
)}
|
||||
style={{ color: featureItemStyle.style?.color }}
|
||||
>
|
||||
{feature.description || 'Feature description goes here'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, { type: 'static' | 'dynamic'; value?: string; source?: string }>;
|
||||
elementStyles?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface HeroRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
export function HeroRenderer({ section, className }: HeroRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const layout = section.layoutVariant || 'default';
|
||||
|
||||
const title = section.props?.title?.value || 'Hero Title';
|
||||
const subtitle = section.props?.subtitle?.value || 'Your amazing subtitle here';
|
||||
const image = section.props?.image?.value;
|
||||
const ctaText = section.props?.cta_text?.value || 'Get Started';
|
||||
const ctaUrl = section.props?.cta_url?.value || '#';
|
||||
|
||||
// Check for dynamic placeholders
|
||||
const isDynamicTitle = section.props?.title?.type === 'dynamic';
|
||||
const isDynamicSubtitle = section.props?.subtitle?.type === 'dynamic';
|
||||
const isDynamicImage = section.props?.image?.type === 'dynamic';
|
||||
|
||||
// Element Styles
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary', // Mapping secondary to sans for now if primary is assumed default
|
||||
'font-serif': styles.fontFamily === 'primary', // Mapping primary to serif (headings) - ADJUST AS NEEDED
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
textAlign: styles.textAlign
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const subtitleStyle = getTextStyles('subtitle');
|
||||
const ctaStyle = getTextStyles('cta_text'); // For button
|
||||
|
||||
// Helper for image styles
|
||||
const imageStyle = section.elementStyles?.['image'] || {};
|
||||
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (layout === 'hero-left-image' || layout === 'hero-right-image') {
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
'max-w-6xl mx-auto flex items-center gap-12',
|
||||
layout === 'hero-right-image' ? 'flex-col md:flex-row-reverse' : 'flex-col md:flex-row',
|
||||
'flex-wrap md:flex-nowrap'
|
||||
)}>
|
||||
{/* Image */}
|
||||
<div className="w-full md:w-1/2">
|
||||
<div
|
||||
className="rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: imageStyle.backgroundColor,
|
||||
width: imageStyle.width || 'auto',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-auto block"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-64 md:h-80 bg-gray-300 flex items-center justify-center">
|
||||
<span className="text-gray-500">
|
||||
{isDynamicImage ? `◆ ${section.props?.image?.source}` : 'No Image'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="w-full md:w-1/2 space-y-4">
|
||||
<h1
|
||||
className={cn("font-bold", titleStyle.classNames || "text-3xl md:text-5xl")}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{isDynamicTitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
className={cn("opacity-90", subtitleStyle.classNames || "text-lg md:text-xl")}
|
||||
style={subtitleStyle.style}
|
||||
>
|
||||
{isDynamicSubtitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{subtitle}
|
||||
</p>
|
||||
{ctaText && (
|
||||
<button
|
||||
className={cn(
|
||||
"px-6 py-3 rounded-lg transition hover:opacity-90",
|
||||
!ctaStyle.style?.backgroundColor && "bg-white",
|
||||
!ctaStyle.style?.color && "text-gray-900",
|
||||
ctaStyle.classNames
|
||||
)}
|
||||
style={ctaStyle.style}
|
||||
>
|
||||
{ctaText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default centered layout
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8 text-center', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<h1
|
||||
className={cn("font-bold", titleStyle.classNames || "text-4xl md:text-6xl")}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{isDynamicTitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{title}
|
||||
</h1>
|
||||
<p
|
||||
className={cn("opacity-90 max-w-2xl mx-auto", subtitleStyle.classNames || "text-lg md:text-2xl")}
|
||||
style={subtitleStyle.style}
|
||||
>
|
||||
{isDynamicSubtitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
{/* Image with Wrapper for Background */}
|
||||
<div
|
||||
className={cn("mx-auto", imageStyle.width ? "" : "max-w-3xl w-full")}
|
||||
style={{ backgroundColor: imageStyle.backgroundColor, width: imageStyle.width || 'auto', maxWidth: '100%' }}
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className={cn(
|
||||
"w-full rounded-xl shadow-lg mt-8",
|
||||
!imageStyle.height && "h-auto", // Default height if not specified
|
||||
!imageStyle.objectFit && "object-cover" // Default fit if not specified
|
||||
)}
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
height: imageStyle.height,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
) : isDynamicImage ? (
|
||||
<div className="w-full h-64 bg-gray-300 rounded-xl flex items-center justify-center mt-8">
|
||||
<span className="text-gray-500">◆ {section.props?.image?.source}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{ctaText && (
|
||||
<button
|
||||
className={cn(
|
||||
"px-8 py-4 rounded-lg transition hover:opacity-90 mt-4",
|
||||
!ctaStyle.style?.backgroundColor && "bg-white",
|
||||
!ctaStyle.style?.color && "text-gray-900",
|
||||
ctaStyle.classNames || "font-semibold"
|
||||
)}
|
||||
style={ctaStyle.style}
|
||||
>
|
||||
{ctaText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Section } from '../../store/usePageEditorStore';
|
||||
|
||||
|
||||
|
||||
interface ImageTextRendererProps {
|
||||
section: Section;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLOR_SCHEMES: Record<string, { bg: string; text: string }> = {
|
||||
default: { bg: '', text: 'text-gray-900' },
|
||||
primary: { bg: 'wn-primary-bg', text: 'text-white' },
|
||||
secondary: { bg: 'wn-secondary-bg', text: 'text-white' },
|
||||
muted: { bg: 'bg-gray-50', text: 'text-gray-700' },
|
||||
gradient: { bg: 'wn-gradient-bg', text: 'text-white' },
|
||||
};
|
||||
|
||||
export function ImageTextRenderer({ section, className }: ImageTextRendererProps) {
|
||||
const scheme = COLOR_SCHEMES[section.colorScheme || 'default'];
|
||||
const layout = section.layoutVariant || 'image-left';
|
||||
const isImageRight = layout === 'image-right';
|
||||
|
||||
const title = section.props?.title?.value || 'Section Title';
|
||||
const text = section.props?.text?.value || 'Your descriptive text goes here. Edit this section to add your own content.';
|
||||
const image = section.props?.image?.value;
|
||||
|
||||
const isDynamicTitle = section.props?.title?.type === 'dynamic';
|
||||
const isDynamicText = section.props?.text?.type === 'dynamic';
|
||||
const isDynamicImage = section.props?.image?.type === 'dynamic';
|
||||
|
||||
const cta_text = section.props?.cta_text?.value;
|
||||
const cta_url = section.props?.cta_url?.value;
|
||||
|
||||
// Helper to get text styles (including font family)
|
||||
const getTextStyles = (elementName: string) => {
|
||||
const styles = section.elementStyles?.[elementName] || {};
|
||||
return {
|
||||
classNames: cn(
|
||||
styles.fontSize,
|
||||
styles.fontWeight,
|
||||
{
|
||||
'font-sans': styles.fontFamily === 'secondary',
|
||||
'font-serif': styles.fontFamily === 'primary',
|
||||
}
|
||||
),
|
||||
style: {
|
||||
color: styles.color,
|
||||
textAlign: styles.textAlign,
|
||||
backgroundColor: styles.backgroundColor
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const titleStyle = getTextStyles('title');
|
||||
const textStyle = getTextStyles('text');
|
||||
const imageStyle = section.elementStyles?.['image'] || {};
|
||||
|
||||
const buttonStyle = getTextStyles('button');
|
||||
|
||||
// Helper to get background style for dynamic schemes
|
||||
const getBackgroundStyle = () => {
|
||||
if (scheme.bg === 'wn-gradient-bg') {
|
||||
return { backgroundImage: 'linear-gradient(135deg, var(--wn-gradient-start, #9333ea), var(--wn-gradient-end, #3b82f6))' };
|
||||
}
|
||||
if (scheme.bg === 'wn-primary-bg') {
|
||||
return { backgroundColor: 'var(--wn-primary, #1a1a1a)' };
|
||||
}
|
||||
if (scheme.bg === 'wn-secondary-bg') {
|
||||
return { backgroundColor: 'var(--wn-secondary, #6b7280)' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('py-12 px-4 md:py-20 md:px-8', !scheme.bg.startsWith('wn-') && scheme.bg, scheme.text, className)}
|
||||
style={getBackgroundStyle()}
|
||||
>
|
||||
<div className={cn(
|
||||
'max-w-6xl mx-auto flex items-center gap-12',
|
||||
isImageRight ? 'flex-col md:flex-row-reverse' : 'flex-col md:flex-row',
|
||||
'flex-wrap md:flex-nowrap'
|
||||
)}>
|
||||
{/* Image */}
|
||||
<div className="w-full md:w-1/2" style={{ backgroundColor: imageStyle.backgroundColor }}>
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-auto rounded-xl shadow-lg"
|
||||
style={{
|
||||
objectFit: imageStyle.objectFit,
|
||||
width: imageStyle.width,
|
||||
maxWidth: '100%',
|
||||
height: imageStyle.height,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-64 md:h-80 bg-gray-200 rounded-xl flex items-center justify-center">
|
||||
<span className="text-gray-400">
|
||||
{isDynamicImage ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-orange-400">◆</span>
|
||||
{section.props?.image?.source}
|
||||
</span>
|
||||
) : (
|
||||
'Add Image'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="w-full md:w-1/2 space-y-4">
|
||||
<h2
|
||||
className={cn(
|
||||
"text-2xl md:text-3xl font-bold",
|
||||
titleStyle.classNames
|
||||
)}
|
||||
style={titleStyle.style}
|
||||
>
|
||||
{isDynamicTitle && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{title}
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"text-lg opacity-90 leading-relaxed",
|
||||
textStyle.classNames
|
||||
)}
|
||||
style={textStyle.style}
|
||||
>
|
||||
{isDynamicText && <span className="text-orange-400 mr-2">◆</span>}
|
||||
{text}
|
||||
</p>
|
||||
|
||||
{cta_text && cta_url && (
|
||||
<div className="pt-4">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2",
|
||||
!buttonStyle.style?.backgroundColor && "bg-blue-600",
|
||||
!buttonStyle.style?.color && "text-white",
|
||||
buttonStyle.classNames
|
||||
)}
|
||||
style={buttonStyle.style}
|
||||
>
|
||||
{cta_text}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { HeroRenderer } from './HeroRenderer';
|
||||
export { ContentRenderer } from './ContentRenderer';
|
||||
export { ImageTextRenderer } from './ImageTextRenderer';
|
||||
export { FeatureGridRenderer } from './FeatureGridRenderer';
|
||||
export { CTABannerRenderer } from './CTABannerRenderer';
|
||||
export { ContactFormRenderer } from './ContactFormRenderer';
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Plus, FileText, Layout, Trash2, Eye, Settings } from 'lucide-react';
|
||||
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PageSidebar } from './components/PageSidebar';
|
||||
import { SectionEditor } from './components/SectionEditor';
|
||||
import { PageSettings } from './components/PageSettings';
|
||||
import { CanvasRenderer } from './components/CanvasRenderer';
|
||||
import { InspectorPanel } from './components/InspectorPanel';
|
||||
import { CreatePageModal } from './components/CreatePageModal';
|
||||
import { usePageEditorStore, Section } from './store/usePageEditorStore';
|
||||
|
||||
// Types
|
||||
interface PageItem {
|
||||
@@ -23,75 +23,109 @@ interface PageItem {
|
||||
icon?: string;
|
||||
has_template?: boolean;
|
||||
permalink_base?: string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PageStructure {
|
||||
type: 'page' | 'template';
|
||||
sections: Section[];
|
||||
updated_at?: string;
|
||||
isFrontPage?: boolean;
|
||||
}
|
||||
|
||||
export default function AppearancePages() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedPage, setSelectedPage] = useState<PageItem | null>(null);
|
||||
const [selectedSection, setSelectedSection] = useState<Section | null>(null);
|
||||
const [structure, setStructure] = useState<PageStructure | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Zustand store
|
||||
const {
|
||||
currentPage,
|
||||
sections,
|
||||
selectedSectionId,
|
||||
deviceMode,
|
||||
inspectorCollapsed,
|
||||
hasUnsavedChanges,
|
||||
isLoading,
|
||||
availableSources,
|
||||
setCurrentPage,
|
||||
setSections,
|
||||
setSelectedSection,
|
||||
setDeviceMode,
|
||||
setInspectorCollapsed,
|
||||
setAvailableSources,
|
||||
setIsLoading,
|
||||
addSection,
|
||||
deleteSection,
|
||||
duplicateSection,
|
||||
moveSection,
|
||||
reorderSections,
|
||||
updateSectionProp,
|
||||
updateSectionLayout,
|
||||
updateSectionColorScheme,
|
||||
updateSectionStyles,
|
||||
updateElementStyles,
|
||||
markAsSaved,
|
||||
setAsSpaLanding,
|
||||
unsetSpaLanding,
|
||||
} = usePageEditorStore();
|
||||
|
||||
// Get selected section object
|
||||
const selectedSection = sections.find(s => s.id === selectedSectionId) || null;
|
||||
|
||||
// Fetch all pages and templates
|
||||
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
|
||||
queryKey: ['pages'],
|
||||
queryFn: async () => {
|
||||
// api.get returns JSON directly (not wrapped in { data: ... })
|
||||
const response = await api.get('/pages');
|
||||
return response; // Return response directly
|
||||
// Map API snake_case to frontend camelCase
|
||||
return response.map((p: any) => ({
|
||||
...p,
|
||||
isSpaLanding: !!p.is_spa_frontpage
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch selected page/template structure
|
||||
const { data: pageData, isLoading: pageLoading } = useQuery({
|
||||
queryKey: ['page-structure', selectedPage?.type, selectedPage?.slug || selectedPage?.cpt],
|
||||
queryKey: ['page-structure', currentPage?.type, currentPage?.slug || currentPage?.cpt],
|
||||
queryFn: async () => {
|
||||
if (!selectedPage) return null;
|
||||
const endpoint = selectedPage.type === 'page'
|
||||
? `/pages/${selectedPage.slug}`
|
||||
: `/templates/${selectedPage.cpt}`;
|
||||
// api.get returns JSON directly
|
||||
if (!currentPage) return null;
|
||||
const endpoint = currentPage.type === 'page'
|
||||
? `/pages/${currentPage.slug}`
|
||||
: `/templates/${currentPage.cpt}`;
|
||||
const response = await api.get(endpoint);
|
||||
return response; // Return response directly
|
||||
return response;
|
||||
},
|
||||
enabled: !!selectedPage,
|
||||
enabled: !!currentPage,
|
||||
});
|
||||
|
||||
// Update local structure when page data loads
|
||||
// Update store when page data loads
|
||||
useEffect(() => {
|
||||
if (pageData?.structure) {
|
||||
setStructure(pageData.structure);
|
||||
setHasUnsavedChanges(false);
|
||||
if (pageData?.structure?.sections) {
|
||||
setSections(pageData.structure.sections);
|
||||
markAsSaved();
|
||||
}
|
||||
}, [pageData]);
|
||||
if (pageData?.available_sources) {
|
||||
setAvailableSources(pageData.available_sources);
|
||||
}
|
||||
// Sync isFrontPage if returned from single page API (optional, but good practice)
|
||||
if (pageData?.is_front_page !== undefined && currentPage) {
|
||||
setCurrentPage({ ...currentPage, isFrontPage: !!pageData.is_front_page });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageData, setSections, markAsSaved, setAvailableSources]); // Removed currentPage from dependency to avoid loop
|
||||
|
||||
// Update loading state
|
||||
useEffect(() => {
|
||||
setIsLoading(pageLoading);
|
||||
}, [pageLoading, setIsLoading]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedPage || !structure) return;
|
||||
const endpoint = selectedPage.type === 'page'
|
||||
? `/pages/${selectedPage.slug}`
|
||||
: `/templates/${selectedPage.cpt}`;
|
||||
return api.post(endpoint, { sections: structure.sections });
|
||||
if (!currentPage) return;
|
||||
const endpoint = currentPage.type === 'page'
|
||||
? `/pages/${currentPage.slug}`
|
||||
: `/templates/${currentPage.cpt}`;
|
||||
return api.post(endpoint, { sections });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Page saved successfully'));
|
||||
setHasUnsavedChanges(false);
|
||||
markAsSaved();
|
||||
queryClient.invalidateQueries({ queryKey: ['page-structure'] });
|
||||
},
|
||||
onError: () => {
|
||||
@@ -99,166 +133,244 @@ export default function AppearancePages() {
|
||||
},
|
||||
});
|
||||
|
||||
// Handle section update
|
||||
const handleSectionUpdate = (updatedSection: Section) => {
|
||||
if (!structure) return;
|
||||
const newSections = structure.sections.map(s =>
|
||||
s.id === updatedSection.id ? updatedSection : s
|
||||
);
|
||||
setStructure({ ...structure, sections: newSections });
|
||||
setSelectedSection(updatedSection);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
return api.del(`/pages/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Page deleted successfully'));
|
||||
markAsSaved(); // Clear unsaved flag
|
||||
setCurrentPage(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to delete page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Add new section
|
||||
const handleAddSection = (sectionType: string) => {
|
||||
if (!structure) {
|
||||
setStructure({
|
||||
type: selectedPage?.type || 'page',
|
||||
sections: [],
|
||||
// Set as SPA Landing mutation
|
||||
const setSpaLandingMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
return api.post(`/pages/${id}/set-as-spa-landing`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Set as SPA Landing Page'));
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
// Update local state is handled by re-fetching pages or we can optimistic update
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, isSpaLanding: true });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to set SPA Landing Page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Unset SPA Landing mutation
|
||||
const unsetSpaLandingMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return api.post(`/pages/unset-spa-landing`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__('Unset SPA Landing Page'));
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
if (currentPage) {
|
||||
setCurrentPage({ ...currentPage, isSpaLanding: false });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__('Failed to unset SPA Landing Page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Handle page selection
|
||||
const handleSelectPage = (page: PageItem) => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm(__('You have unsaved changes. Continue?'))) return;
|
||||
}
|
||||
if (page.type === 'page') {
|
||||
setCurrentPage({
|
||||
...page,
|
||||
isSpaLanding: !!(page as any).isSpaLanding
|
||||
});
|
||||
}
|
||||
const newSection: Section = {
|
||||
id: `section-${Date.now()}`,
|
||||
type: sectionType,
|
||||
layoutVariant: 'default',
|
||||
colorScheme: 'default',
|
||||
props: {},
|
||||
} else {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
setStructure(prev => ({
|
||||
...prev!,
|
||||
sections: [...(prev?.sections || []), newSection],
|
||||
}));
|
||||
setSelectedSection(newSection);
|
||||
setHasUnsavedChanges(true);
|
||||
setSelectedSection(null);
|
||||
};
|
||||
|
||||
// Delete section
|
||||
const handleDeleteSection = (sectionId: string) => {
|
||||
if (!structure) return;
|
||||
setStructure({
|
||||
...structure,
|
||||
sections: structure.sections.filter(s => s.id !== sectionId),
|
||||
});
|
||||
if (selectedSection?.id === sectionId) {
|
||||
setSelectedSection(null);
|
||||
const handleDiscard = () => {
|
||||
if (pageData?.structure?.sections) {
|
||||
setSections(pageData.structure.sections);
|
||||
markAsSaved();
|
||||
toast.success(__('Changes discarded'));
|
||||
}
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
// Move section
|
||||
const handleMoveSection = (sectionId: string, direction: 'up' | 'down') => {
|
||||
if (!structure) return;
|
||||
const index = structure.sections.findIndex(s => s.id === sectionId);
|
||||
if (index === -1) return;
|
||||
const handleDeletePage = () => {
|
||||
if (!currentPage || !currentPage.id) return;
|
||||
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= structure.sections.length) return;
|
||||
|
||||
const newSections = [...structure.sections];
|
||||
[newSections[index], newSections[newIndex]] = [newSections[newIndex], newSections[index]];
|
||||
setStructure({ ...structure, sections: newSections });
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
// Reorder sections (drag-and-drop)
|
||||
const handleReorderSections = (newSections: Section[]) => {
|
||||
if (!structure) return;
|
||||
setStructure({ ...structure, sections: newSections });
|
||||
setHasUnsavedChanges(true);
|
||||
if (confirm(__('Are you sure you want to delete this page? This action cannot be undone.'))) {
|
||||
deleteMutation.mutate(currentPage.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col">
|
||||
<div className={
|
||||
cn(
|
||||
"flex flex-col bg-white transition-all duration-300",
|
||||
isFullscreen ? "fixed inset-0 z-[100] h-screen" : "h-[calc(100vh-64px)]"
|
||||
)
|
||||
} >
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
< div className="flex items-center justify-between px-6 py-3 border-b bg-white" >
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{__('Page Editor')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedPage ? selectedPage.title : __('Select a page to edit')}
|
||||
{currentPage ? currentPage.title : __('Select a page to edit')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
title={isFullscreen ? __('Exit Fullscreen') : __('Enter Fullscreen')}
|
||||
className="mr-2"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</Button>
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
||||
<>
|
||||
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDiscard}
|
||||
>
|
||||
<Undo2 className="w-4 h-4 mr-2" />
|
||||
{__('Discard')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Create Page')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!hasUnsavedChanges || saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? __('Saving...') : __('Save Changes')}
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saveMutation.isPending ? __('Saving...') : __('Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
{/* 3-Column Layout */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 3-Column Layout: Sidebar | Canvas | Inspector */}
|
||||
< div className="flex-1 flex overflow-hidden" >
|
||||
{/* Left Column: Pages List */}
|
||||
<PageSidebar
|
||||
< PageSidebar
|
||||
pages={pages}
|
||||
selectedPage={selectedPage}
|
||||
onSelectPage={(page) => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm(__('You have unsaved changes. Continue?'))) return;
|
||||
}
|
||||
setSelectedPage(page);
|
||||
setSelectedSection(null);
|
||||
}}
|
||||
selectedPage={currentPage}
|
||||
onSelectPage={handleSelectPage}
|
||||
isLoading={pagesLoading}
|
||||
/>
|
||||
|
||||
{/* Center Column: Section Editor */}
|
||||
<div className="flex-1 bg-gray-50 overflow-y-auto p-6">
|
||||
{selectedPage ? (
|
||||
<SectionEditor
|
||||
sections={structure?.sections || []}
|
||||
selectedSection={selectedSection}
|
||||
{/* Center Column: Canvas Renderer */}
|
||||
{
|
||||
currentPage ? (
|
||||
<CanvasRenderer
|
||||
sections={sections}
|
||||
selectedSectionId={selectedSectionId}
|
||||
deviceMode={deviceMode}
|
||||
onSelectSection={setSelectedSection}
|
||||
onAddSection={handleAddSection}
|
||||
onDeleteSection={handleDeleteSection}
|
||||
onMoveSection={handleMoveSection}
|
||||
onReorderSections={handleReorderSections}
|
||||
isTemplate={selectedPage.type === 'template'}
|
||||
cpt={selectedPage.cpt}
|
||||
isLoading={pageLoading}
|
||||
onAddSection={addSection}
|
||||
onDeleteSection={deleteSection}
|
||||
onDuplicateSection={duplicateSection}
|
||||
onMoveSection={moveSection}
|
||||
onReorderSections={reorderSections}
|
||||
onDeviceModeChange={setDeviceMode}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-gray-400">
|
||||
<div className="flex-1 bg-gray-100 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<Layout className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>{__('Select a page from the sidebar to start editing')}</p>
|
||||
<Layout className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">{__('Select a page from the sidebar')}</p>
|
||||
<p className="text-sm mt-2">{__('Edit pages and templates visually')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Right Column: Settings & Preview */}
|
||||
<PageSettings
|
||||
page={selectedPage}
|
||||
section={selectedSection}
|
||||
sections={structure?.sections || []}
|
||||
onSectionUpdate={handleSectionUpdate}
|
||||
isTemplate={selectedPage?.type === 'template'}
|
||||
availableSources={pageData?.available_sources || []}
|
||||
/>
|
||||
</div>
|
||||
{/* Right Column: Inspector Panel */}
|
||||
{
|
||||
currentPage && (
|
||||
<InspectorPanel
|
||||
page={currentPage}
|
||||
selectedSection={selectedSection}
|
||||
isCollapsed={inspectorCollapsed}
|
||||
isTemplate={currentPage.type === 'template'}
|
||||
availableSources={availableSources}
|
||||
onToggleCollapse={() => setInspectorCollapsed(!inspectorCollapsed)}
|
||||
onSectionPropChange={(propName, value) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionProp(selectedSectionId, propName, value);
|
||||
}
|
||||
}}
|
||||
onLayoutChange={(layout) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionLayout(selectedSectionId, layout);
|
||||
}
|
||||
}}
|
||||
onColorSchemeChange={(scheme) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionColorScheme(selectedSectionId, scheme);
|
||||
}
|
||||
}}
|
||||
onSectionStylesChange={(styles) => {
|
||||
if (selectedSectionId) {
|
||||
updateSectionStyles(selectedSectionId, styles);
|
||||
}
|
||||
}}
|
||||
onElementStylesChange={(fieldName, styles) => {
|
||||
if (selectedSectionId) {
|
||||
updateElementStyles(selectedSectionId, fieldName, styles);
|
||||
}
|
||||
}}
|
||||
onDeleteSection={() => {
|
||||
if (selectedSectionId) {
|
||||
deleteSection(selectedSectionId);
|
||||
}
|
||||
}}
|
||||
onSetAsSpaLanding={() => {
|
||||
if (currentPage?.id) {
|
||||
setSpaLandingMutation.mutate(currentPage.id);
|
||||
}
|
||||
}}
|
||||
onUnsetSpaLanding={() => unsetSpaLandingMutation.mutate()}
|
||||
onDeletePage={handleDeletePage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
|
||||
{/* Create Page Modal */}
|
||||
<CreatePageModal
|
||||
< CreatePageModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
onCreated={(newPage) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
setSelectedPage(newPage);
|
||||
}}
|
||||
setCurrentPage(newPage);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Simple ID generator (replaces uuid)
|
||||
const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
export interface SectionProp {
|
||||
type: 'static' | 'dynamic';
|
||||
value?: any;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface SectionStyles {
|
||||
backgroundColor?: string;
|
||||
backgroundImage?: string;
|
||||
backgroundOverlay?: number; // 0-100 opacity
|
||||
paddingTop?: string;
|
||||
paddingBottom?: string;
|
||||
contentWidth?: 'full' | 'contained';
|
||||
heightPreset?: string;
|
||||
}
|
||||
|
||||
export interface ElementStyle {
|
||||
color?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
fontFamily?: 'primary' | 'secondary';
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
|
||||
// Image specific
|
||||
objectFit?: 'cover' | 'contain' | 'fill';
|
||||
backgroundColor?: string; // Wrapper BG
|
||||
width?: string;
|
||||
height?: string;
|
||||
|
||||
// Link specific
|
||||
textDecoration?: 'none' | 'underline';
|
||||
hoverColor?: string;
|
||||
|
||||
// Button/Box specific
|
||||
borderColor?: string;
|
||||
borderWidth?: string;
|
||||
borderRadius?: string;
|
||||
padding?: string;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
styles?: SectionStyles;
|
||||
elementStyles?: Record<string, ElementStyle>;
|
||||
props: Record<string, SectionProp>;
|
||||
}
|
||||
|
||||
export interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
isSpaLanding?: boolean;
|
||||
}
|
||||
|
||||
interface PageEditorState {
|
||||
// Current page/template being edited
|
||||
currentPage: PageItem | null;
|
||||
|
||||
// Sections for the current page
|
||||
sections: Section[];
|
||||
|
||||
// Selection & interaction
|
||||
selectedSectionId: string | null;
|
||||
hoveredSectionId: string | null;
|
||||
|
||||
// UI state
|
||||
deviceMode: 'desktop' | 'mobile';
|
||||
inspectorCollapsed: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
isLoading: boolean;
|
||||
|
||||
// Available sources for dynamic fields (CPT templates)
|
||||
availableSources: { value: string; label: string }[];
|
||||
|
||||
// Actions
|
||||
setCurrentPage: (page: PageItem | null) => void;
|
||||
setSections: (sections: Section[]) => void;
|
||||
setSelectedSection: (id: string | null) => void;
|
||||
setHoveredSection: (id: string | null) => void;
|
||||
setDeviceMode: (mode: 'desktop' | 'mobile') => void;
|
||||
setInspectorCollapsed: (collapsed: boolean) => void;
|
||||
setAvailableSources: (sources: { value: string; label: string }[]) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
|
||||
// Section actions
|
||||
addSection: (type: string, index?: number) => void;
|
||||
deleteSection: (id: string) => void;
|
||||
duplicateSection: (id: string) => void;
|
||||
moveSection: (id: string, direction: 'up' | 'down') => void;
|
||||
reorderSections: (sections: Section[]) => void;
|
||||
updateSectionProp: (sectionId: string, propName: string, value: SectionProp) => void;
|
||||
updateSectionLayout: (sectionId: string, layoutVariant: string) => void;
|
||||
updateSectionColorScheme: (sectionId: string, colorScheme: string) => void;
|
||||
updateSectionStyles: (sectionId: string, styles: Partial<SectionStyles>) => void;
|
||||
updateElementStyles: (sectionId: string, fieldName: string, styles: Partial<ElementStyle>) => void;
|
||||
|
||||
// Page actions
|
||||
setAsSpaLanding: () => Promise<void>;
|
||||
unsetSpaLanding: () => Promise<void>;
|
||||
|
||||
// Persistence
|
||||
markAsChanged: () => void;
|
||||
markAsSaved: () => void;
|
||||
reset: () => void;
|
||||
savePage: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Default props for each section type
|
||||
const DEFAULT_SECTION_PROPS: Record<string, Record<string, SectionProp>> = {
|
||||
hero: {
|
||||
title: { type: 'static', value: 'Welcome to Our Site' },
|
||||
subtitle: { type: 'static', value: 'Discover amazing products and services' },
|
||||
image: { type: 'static', value: '' },
|
||||
cta_text: { type: 'static', value: 'Get Started' },
|
||||
cta_url: { type: 'static', value: '#' },
|
||||
},
|
||||
content: {
|
||||
content: { type: 'static', value: 'Add your content here. You can write rich text and format it as needed.' },
|
||||
cta_text: { type: 'static', value: '' },
|
||||
cta_url: { type: 'static', value: '' },
|
||||
},
|
||||
'image-text': {
|
||||
title: { type: 'static', value: 'Section Title' },
|
||||
text: { type: 'static', value: 'Your description text goes here. Add compelling content to engage visitors.' },
|
||||
image: { type: 'static', value: '' },
|
||||
cta_text: { type: 'static', value: '' },
|
||||
cta_url: { type: 'static', value: '' },
|
||||
},
|
||||
'feature-grid': {
|
||||
heading: { type: 'static', value: 'Our Features' },
|
||||
features: { type: 'static', value: '' },
|
||||
},
|
||||
'cta-banner': {
|
||||
title: { type: 'static', value: 'Ready to get started?' },
|
||||
text: { type: 'static', value: 'Join thousands of happy customers today.' },
|
||||
button_text: { type: 'static', value: 'Get Started' },
|
||||
button_url: { type: 'static', value: '#' },
|
||||
},
|
||||
'contact-form': {
|
||||
title: { type: 'static', value: 'Contact Us' },
|
||||
webhook_url: { type: 'static', value: '' },
|
||||
redirect_url: { type: 'static', value: '' },
|
||||
},
|
||||
};
|
||||
|
||||
// Define a SECTION_CONFIGS object based on DEFAULT_SECTION_PROPS for the new addSection logic
|
||||
const SECTION_CONFIGS: Record<string, { defaultProps: Record<string, SectionProp>; defaultStyles?: SectionStyles }> = {
|
||||
hero: { defaultProps: DEFAULT_SECTION_PROPS.hero, defaultStyles: { contentWidth: 'full' } },
|
||||
content: { defaultProps: DEFAULT_SECTION_PROPS.content, defaultStyles: { contentWidth: 'full' } },
|
||||
'image-text': { defaultProps: DEFAULT_SECTION_PROPS['image-text'], defaultStyles: { contentWidth: 'contained' } },
|
||||
'feature-grid': { defaultProps: DEFAULT_SECTION_PROPS['feature-grid'], defaultStyles: { contentWidth: 'contained' } },
|
||||
'cta-banner': { defaultProps: DEFAULT_SECTION_PROPS['cta-banner'], defaultStyles: { contentWidth: 'full' } },
|
||||
'contact-form': { defaultProps: DEFAULT_SECTION_PROPS['contact-form'], defaultStyles: { contentWidth: 'contained' } },
|
||||
};
|
||||
|
||||
|
||||
export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||
// Initial state
|
||||
currentPage: null,
|
||||
sections: [],
|
||||
selectedSectionId: null,
|
||||
hoveredSectionId: null,
|
||||
deviceMode: 'desktop',
|
||||
inspectorCollapsed: false,
|
||||
hasUnsavedChanges: false,
|
||||
isLoading: false,
|
||||
availableSources: [],
|
||||
|
||||
// Setters
|
||||
setCurrentPage: (currentPage) => set({ currentPage }),
|
||||
setSections: (sections) => set({ sections, hasUnsavedChanges: true }),
|
||||
setSelectedSection: (selectedSectionId) => set({ selectedSectionId }),
|
||||
setHoveredSection: (hoveredSectionId) => set({ hoveredSectionId }),
|
||||
setDeviceMode: (deviceMode) => set({ deviceMode }),
|
||||
setInspectorCollapsed: (inspectorCollapsed) => set({ inspectorCollapsed }),
|
||||
setAvailableSources: (availableSources) => set({ availableSources }),
|
||||
setIsLoading: (isLoading) => set({ isLoading }),
|
||||
|
||||
// Section actions
|
||||
addSection: (type, index) => {
|
||||
const { sections } = get();
|
||||
const sectionConfig = SECTION_CONFIGS[type];
|
||||
|
||||
if (!sectionConfig) return;
|
||||
|
||||
const newSection: Section = {
|
||||
id: generateId(),
|
||||
type,
|
||||
props: { ...sectionConfig.defaultProps },
|
||||
styles: { ...sectionConfig.defaultStyles }
|
||||
};
|
||||
|
||||
const newSections = [...sections];
|
||||
if (typeof index === 'number') {
|
||||
newSections.splice(index, 0, newSection);
|
||||
} else {
|
||||
newSections.push(newSection);
|
||||
}
|
||||
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
|
||||
// Select the new section
|
||||
set({ selectedSectionId: newSection.id });
|
||||
},
|
||||
|
||||
deleteSection: (id) => {
|
||||
const { sections, selectedSectionId } = get();
|
||||
const newSections = sections.filter(s => s.id !== id);
|
||||
|
||||
set({
|
||||
sections: newSections,
|
||||
hasUnsavedChanges: true,
|
||||
selectedSectionId: selectedSectionId === id ? null : selectedSectionId
|
||||
});
|
||||
},
|
||||
|
||||
duplicateSection: (id) => {
|
||||
const { sections } = get();
|
||||
const index = sections.findIndex(s => s.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const section = sections[index];
|
||||
const newSection: Section = {
|
||||
...JSON.parse(JSON.stringify(section)), // Deep clone
|
||||
id: generateId()
|
||||
};
|
||||
|
||||
const newSections = [...sections];
|
||||
newSections.splice(index + 1, 0, newSection);
|
||||
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
moveSection: (id, direction) => {
|
||||
const { sections } = get();
|
||||
const index = sections.findIndex(s => s.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
if (direction === 'up' && index > 0) {
|
||||
const newSections = [...sections];
|
||||
[newSections[index], newSections[index - 1]] = [newSections[index - 1], newSections[index]];
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
} else if (direction === 'down' && index < sections.length - 1) {
|
||||
const newSections = [...sections];
|
||||
[newSections[index], newSections[index + 1]] = [newSections[index + 1], newSections[index]];
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
}
|
||||
},
|
||||
|
||||
reorderSections: (sections) => {
|
||||
set({ sections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateSectionProp: (sectionId, propName, value) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
props: {
|
||||
...section.props,
|
||||
[propName]: value
|
||||
}
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateSectionLayout: (sectionId, layoutVariant) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
layoutVariant
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateSectionColorScheme: (sectionId, colorScheme) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
colorScheme
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateSectionStyles: (sectionId, styles) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
return {
|
||||
...section,
|
||||
styles: {
|
||||
...section.styles,
|
||||
...styles
|
||||
}
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
updateElementStyles: (sectionId, fieldName, styles) => {
|
||||
const { sections } = get();
|
||||
const newSections = sections.map(section => {
|
||||
if (section.id !== sectionId) return section;
|
||||
|
||||
const newElementStyles = {
|
||||
...section.elementStyles,
|
||||
[fieldName]: {
|
||||
...(section.elementStyles?.[fieldName] || {}),
|
||||
...styles
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...section,
|
||||
elementStyles: newElementStyles
|
||||
};
|
||||
});
|
||||
set({ sections: newSections, hasUnsavedChanges: true });
|
||||
},
|
||||
|
||||
// Page Actions
|
||||
setAsSpaLanding: async () => {
|
||||
const { currentPage } = get();
|
||||
if (!currentPage || !currentPage.id) return;
|
||||
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
|
||||
// Call API to set page as SPA Landing
|
||||
await fetch(`${(window as any).WNW_API.root}/pages/${currentPage.id}/set-as-spa-landing`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-WP-Nonce': (window as any).WNW_API.nonce,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state - only update isSpaLanding, no other properties
|
||||
set({
|
||||
currentPage: {
|
||||
...currentPage,
|
||||
isSpaLanding: true
|
||||
},
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to set SPA landing page:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
unsetSpaLanding: async () => {
|
||||
const { currentPage } = get();
|
||||
if (!currentPage) return;
|
||||
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
|
||||
// Call API to unset SPA Landing
|
||||
await fetch(`${(window as any).WNW_API.root}/pages/unset-spa-landing`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-WP-Nonce': (window as any).WNW_API.nonce,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
set({
|
||||
currentPage: {
|
||||
...currentPage,
|
||||
isSpaLanding: false
|
||||
},
|
||||
isLoading: false
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to unset SPA landing page:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Persistence
|
||||
markAsChanged: () => set({ hasUnsavedChanges: true }),
|
||||
markAsSaved: () => set({ hasUnsavedChanges: false }),
|
||||
|
||||
savePage: async () => {
|
||||
const { currentPage, sections } = get();
|
||||
if (!currentPage) return;
|
||||
|
||||
try {
|
||||
set({ isLoading: true });
|
||||
|
||||
const endpoint = currentPage.type === 'page'
|
||||
? `/pages/${currentPage.slug}`
|
||||
: `/templates/${currentPage.cpt}`;
|
||||
|
||||
await fetch(`${(window as any).WNW_API.root}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-WP-Nonce': (window as any).WNW_API.nonce,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ sections })
|
||||
});
|
||||
|
||||
set({
|
||||
hasUnsavedChanges: false,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save page:', error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
currentPage: null,
|
||||
sections: [],
|
||||
selectedSectionId: null,
|
||||
hoveredSectionId: null,
|
||||
hasUnsavedChanges: false,
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user