feat: Page Editor Phase 2 - Admin UI
- Add AppearancePages component with 3-column layout - Add PageSidebar for listing structural pages and CPT templates - Add SectionEditor with add/delete/reorder functionality - Add PageSettings with layout/color scheme and static/dynamic toggle - Add CreatePageModal for creating new structural pages - Add route at /appearance/pages in admin App.tsx - Build admin-spa successfully
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { FileText, Layout } from 'lucide-react';
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CreatePageModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: (page: PageItem) => void;
|
||||
}
|
||||
|
||||
export function CreatePageModal({ open, onOpenChange, onCreated }: CreatePageModalProps) {
|
||||
const [pageType, setPageType] = useState<'page' | 'template'>('page');
|
||||
const [title, setTitle] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
|
||||
// Create page mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (pageType === 'page') {
|
||||
const response = await api.post('/pages', { title, slug });
|
||||
return response.data;
|
||||
}
|
||||
// For templates, we don't create them - they're auto-created for each CPT
|
||||
return null;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data?.page) {
|
||||
toast.success(__('Page created successfully'));
|
||||
onCreated({
|
||||
id: data.page.id,
|
||||
type: 'page',
|
||||
slug: data.page.slug,
|
||||
title: data.page.title,
|
||||
});
|
||||
onOpenChange(false);
|
||||
setTitle('');
|
||||
setSlug('');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || __('Failed to create page'));
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-generate slug from title
|
||||
const handleTitleChange = (value: string) => {
|
||||
setTitle(value);
|
||||
if (!slug || slug === title.toLowerCase().replace(/\s+/g, '-')) {
|
||||
setSlug(value.toLowerCase().replace(/\s+/g, '-'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Create New Page')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Choose what type of page you want to create.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 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-gray-50">
|
||||
<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">
|
||||
<FileText className="w-4 h-4" />
|
||||
{__('Structural Page')}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{__('Static content like About, Contact, Terms')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg cursor-pointer hover:bg-gray-50 opacity-50">
|
||||
<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">
|
||||
<Layout className="w-4 h-4" />
|
||||
{__('CPT Template')}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{__('Templates are auto-created for each post type')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{/* 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')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">{__('URL Slug')}</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder={__('e.g., about-us')}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
{__('URL will be: ')}yoursite.com/{slug || 'page-slug'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={pageType === 'page' && (!title || !slug) || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? __('Creating...') : __('Create Page')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import React from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Settings, Eye, Smartphone, Monitor, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface AvailableSource {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface PageSettingsProps {
|
||||
page: PageItem | null;
|
||||
section: Section | null;
|
||||
onSectionUpdate: (section: Section) => void;
|
||||
isTemplate?: boolean;
|
||||
availableSources?: AvailableSource[];
|
||||
}
|
||||
|
||||
// Section field configs
|
||||
const SECTION_FIELDS: Record<string, { name: string; type: 'text' | 'textarea' | 'url' | 'image'; dynamic?: boolean }[]> = {
|
||||
hero: [
|
||||
{ name: 'title', type: 'text', dynamic: true },
|
||||
{ name: 'subtitle', type: 'text', dynamic: true },
|
||||
{ name: 'image', type: 'image', dynamic: true },
|
||||
{ name: 'cta_text', type: 'text' },
|
||||
{ name: 'cta_url', type: 'url' },
|
||||
],
|
||||
content: [
|
||||
{ name: 'content', type: 'textarea', dynamic: true },
|
||||
],
|
||||
'image-text': [
|
||||
{ name: 'title', type: 'text', dynamic: true },
|
||||
{ name: 'text', type: 'textarea', dynamic: true },
|
||||
{ name: 'image', type: 'image', dynamic: true },
|
||||
],
|
||||
'feature-grid': [
|
||||
{ name: 'heading', type: 'text' },
|
||||
],
|
||||
'cta-banner': [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'text', type: 'text' },
|
||||
{ name: 'button_text', type: 'text' },
|
||||
{ name: 'button_url', type: 'url' },
|
||||
],
|
||||
'contact-form': [
|
||||
{ name: 'title', type: 'text' },
|
||||
{ name: 'webhook_url', type: 'url' },
|
||||
{ name: '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' },
|
||||
];
|
||||
|
||||
export function PageSettings({
|
||||
page,
|
||||
section,
|
||||
onSectionUpdate,
|
||||
isTemplate = false,
|
||||
availableSources = [],
|
||||
}: PageSettingsProps) {
|
||||
const [previewMode, setPreviewMode] = React.useState<'desktop' | 'mobile'>('desktop');
|
||||
|
||||
// Update section prop
|
||||
const updateProp = (name: string, value: any, isDynamic?: boolean) => {
|
||||
if (!section) return;
|
||||
|
||||
const newProps = { ...section.props };
|
||||
if (isDynamic) {
|
||||
newProps[name] = { type: 'dynamic', source: value };
|
||||
} else {
|
||||
newProps[name] = { type: 'static', value };
|
||||
}
|
||||
|
||||
onSectionUpdate({ ...section, props: newProps });
|
||||
};
|
||||
|
||||
// Get prop value
|
||||
const getPropValue = (name: string): string => {
|
||||
const prop = section?.props[name];
|
||||
if (!prop) return '';
|
||||
if (typeof prop === 'object') {
|
||||
return prop.type === 'dynamic' ? prop.source : prop.value || '';
|
||||
}
|
||||
return String(prop);
|
||||
};
|
||||
|
||||
// Check if prop is dynamic
|
||||
const isPropDynamic = (name: string): boolean => {
|
||||
const prop = section?.props[name];
|
||||
return typeof prop === 'object' && prop?.type === 'dynamic';
|
||||
};
|
||||
|
||||
// Render field based on type
|
||||
const renderField = (field: { name: string; type: string; dynamic?: boolean }) => {
|
||||
const value = getPropValue(field.name);
|
||||
const isDynamic = isPropDynamic(field.name);
|
||||
const fieldLabel = field.name.charAt(0).toUpperCase() + field.name.slice(1).replace('_', ' ');
|
||||
|
||||
return (
|
||||
<div key={field.name} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{fieldLabel}</Label>
|
||||
{field.dynamic && isTemplate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{isDynamic ? '◆ Dynamic' : 'Static'}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isDynamic}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateProp(field.name, 'post_title', true);
|
||||
} else {
|
||||
updateProp(field.name, '', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDynamic && isTemplate ? (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => updateProp(field.name, v, true)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={__('Select source')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSources.map(source => (
|
||||
<SelectItem key={source.value} value={source.value}>
|
||||
{source.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => updateProp(field.name, e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type === 'url' ? 'url' : 'text'}
|
||||
value={value}
|
||||
onChange={(e) => updateProp(field.name, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 border-l bg-white flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Page Info */}
|
||||
{page && !section && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
{isTemplate ? __('Template Settings') : __('Page Settings')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>{__('Type')}</Label>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isTemplate ? __('Template (Dynamic)') : __('Page (Structural)')}
|
||||
</p>
|
||||
</div>
|
||||
{page.url && (
|
||||
<div>
|
||||
<Label>{__('URL')}</Label>
|
||||
<a
|
||||
href={page.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
{page.url}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Section Settings */}
|
||||
{section && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{__('Section Settings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Layout Variant */}
|
||||
{LAYOUT_OPTIONS[section.type] && (
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Layout')}</Label>
|
||||
<Select
|
||||
value={section.layoutVariant || 'default'}
|
||||
onValueChange={(v) => onSectionUpdate({ ...section, layoutVariant: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LAYOUT_OPTIONS[section.type].map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color Scheme */}
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Color Scheme')}</Label>
|
||||
<Select
|
||||
value={section.colorScheme || 'default'}
|
||||
onValueChange={(v) => onSectionUpdate({ ...section, colorScheme: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_SCHEMES.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{__('Content')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{SECTION_FIELDS[section.type]?.map(renderField)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview Toggle */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
{__('Preview')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={previewMode === 'desktop' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('desktop')}
|
||||
>
|
||||
<Monitor className="w-4 h-4 mr-1" />
|
||||
{__('Desktop')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={previewMode === 'mobile' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setPreviewMode('mobile')}
|
||||
>
|
||||
<Smartphone className="w-4 h-4 mr-1" />
|
||||
{__('Mobile')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">
|
||||
{__('Live preview will be available after saving.')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FileText, Layout, Loader2 } from 'lucide-react';
|
||||
|
||||
interface PageItem {
|
||||
id?: number;
|
||||
type: 'page' | 'template';
|
||||
cpt?: string;
|
||||
slug?: string;
|
||||
title: string;
|
||||
has_template?: boolean;
|
||||
permalink_base?: string;
|
||||
}
|
||||
|
||||
interface PageSidebarProps {
|
||||
pages: PageItem[];
|
||||
selectedPage: PageItem | null;
|
||||
onSelectPage: (page: PageItem) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function PageSidebar({ pages, selectedPage, onSelectPage, isLoading }: PageSidebarProps) {
|
||||
const structuralPages = pages.filter(p => p.type === 'page');
|
||||
const templates = pages.filter(p => p.type === 'template');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-60 border-r bg-white flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-60 border-r bg-white flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4">
|
||||
{/* Structural Pages */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
{__('Structural Pages')}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{structuralPages.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">{__('No pages yet')}</p>
|
||||
) : (
|
||||
structuralPages.map((page) => (
|
||||
<button
|
||||
key={`page-${page.id}`}
|
||||
onClick={() => onSelectPage(page)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
'hover:bg-gray-100',
|
||||
selectedPage?.id === page.id && selectedPage?.type === 'page'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
{page.title}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Templates */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-2">
|
||||
<Layout className="w-3.5 h-3.5" />
|
||||
{__('Templates')}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={`template-${template.cpt}`}
|
||||
onClick={() => onSelectPage(template)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
'hover:bg-gray-100',
|
||||
selectedPage?.cpt === template.cpt && selectedPage?.type === 'template'
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
<span className="block">{template.title}</span>
|
||||
{template.permalink_base && (
|
||||
<span className="text-xs text-gray-400">{template.permalink_base}*</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useState } from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
Plus, ChevronUp, ChevronDown, Trash2, GripVertical,
|
||||
LayoutTemplate, Type, Image, Grid3x3, Megaphone, MessageSquare,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
type: string;
|
||||
layoutVariant?: string;
|
||||
colorScheme?: string;
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SectionEditorProps {
|
||||
sections: Section[];
|
||||
selectedSection: Section | null;
|
||||
onSelectSection: (section: Section | null) => void;
|
||||
onAddSection: (type: string) => void;
|
||||
onDeleteSection: (id: string) => void;
|
||||
onMoveSection: (id: string, direction: 'up' | 'down') => void;
|
||||
isTemplate: boolean;
|
||||
cpt?: string;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const SECTION_TYPES = [
|
||||
{ type: 'hero', label: 'Hero', icon: LayoutTemplate },
|
||||
{ type: 'content', label: 'Content', icon: Type },
|
||||
{ type: 'image-text', label: 'Image + Text', icon: Image },
|
||||
{ type: 'feature-grid', label: 'Feature Grid', icon: Grid3x3 },
|
||||
{ type: 'cta-banner', label: 'CTA Banner', icon: Megaphone },
|
||||
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
|
||||
];
|
||||
|
||||
export function SectionEditor({
|
||||
sections,
|
||||
selectedSection,
|
||||
onSelectSection,
|
||||
onAddSection,
|
||||
onDeleteSection,
|
||||
onMoveSection,
|
||||
isTemplate,
|
||||
cpt,
|
||||
isLoading,
|
||||
}: SectionEditorProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{__('Sections')}</h2>
|
||||
{isTemplate && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-1 rounded">
|
||||
{__('Template: ')} {cpt}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections List */}
|
||||
<div className="space-y-3">
|
||||
{sections.map((section, index) => {
|
||||
const sectionType = SECTION_TYPES.find(s => s.type === section.type);
|
||||
const Icon = sectionType?.icon || LayoutTemplate;
|
||||
const hasDynamic = Object.values(section.props).some(
|
||||
p => typeof p === 'object' && p?.type === 'dynamic'
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={section.id}
|
||||
className={cn(
|
||||
'p-4 cursor-pointer transition-all',
|
||||
'hover:shadow-md',
|
||||
selectedSection?.id === section.id
|
||||
? 'ring-2 ring-primary shadow-md'
|
||||
: ''
|
||||
)}
|
||||
onClick={() => onSelectSection(section)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="w-4 h-4 text-gray-400 cursor-grab" />
|
||||
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<Icon className="w-5 h-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{sectionType?.label || section.type}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{section.layoutVariant || 'default'}
|
||||
{hasDynamic && (
|
||||
<span className="ml-2 text-primary">◆ {__('Dynamic')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onMoveSection(section.id, 'up')}
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onMoveSection(section.id, 'down')}
|
||||
disabled={index === sections.length - 1}
|
||||
>
|
||||
<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?'))) {
|
||||
onDeleteSection(section.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{sections.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<LayoutTemplate className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>{__('No sections yet. Add your first section.')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Section Button */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{__('Add Section')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
{SECTION_TYPES.map((sectionType) => {
|
||||
const Icon = sectionType.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={sectionType.type}
|
||||
onClick={() => onAddSection(sectionType.type)}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{sectionType.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user