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:
@@ -261,6 +261,7 @@ import AppearanceCart from '@/routes/Appearance/Cart';
|
|||||||
import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
||||||
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||||
|
import AppearancePages from '@/routes/Appearance/Pages';
|
||||||
import MarketingIndex from '@/routes/Marketing';
|
import MarketingIndex from '@/routes/Marketing';
|
||||||
import Newsletter from '@/routes/Marketing/Newsletter';
|
import Newsletter from '@/routes/Marketing/Newsletter';
|
||||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||||
@@ -608,6 +609,7 @@ function AppRoutes() {
|
|||||||
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
|
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
|
||||||
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
|
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
|
||||||
<Route path="/appearance/account" element={<AppearanceAccount />} />
|
<Route path="/appearance/account" element={<AppearanceAccount />} />
|
||||||
|
<Route path="/appearance/pages" element={<AppearancePages />} />
|
||||||
|
|
||||||
{/* Marketing */}
|
{/* Marketing */}
|
||||||
<Route path="/marketing" element={<MarketingIndex />} />
|
<Route path="/marketing" element={<MarketingIndex />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
admin-spa/src/routes/Appearance/Pages/index.tsx
Normal file
253
admin-spa/src/routes/Appearance/Pages/index.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import React, { useState, useEffect } 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 { toast } from 'sonner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { PageSidebar } from './components/PageSidebar';
|
||||||
|
import { SectionEditor } from './components/SectionEditor';
|
||||||
|
import { PageSettings } from './components/PageSettings';
|
||||||
|
import { CreatePageModal } from './components/CreatePageModal';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface PageItem {
|
||||||
|
id?: number;
|
||||||
|
type: 'page' | 'template';
|
||||||
|
cpt?: string;
|
||||||
|
slug?: string;
|
||||||
|
title: string;
|
||||||
|
url?: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Fetch all pages and templates
|
||||||
|
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
|
||||||
|
queryKey: ['pages'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/pages');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch selected page/template structure
|
||||||
|
const { data: pageData, isLoading: pageLoading } = useQuery({
|
||||||
|
queryKey: ['page-structure', selectedPage?.type, selectedPage?.slug || selectedPage?.cpt],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!selectedPage) return null;
|
||||||
|
const endpoint = selectedPage.type === 'page'
|
||||||
|
? `/pages/${selectedPage.slug}`
|
||||||
|
: `/templates/${selectedPage.cpt}`;
|
||||||
|
const response = await api.get(endpoint);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!selectedPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local structure when page data loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageData?.structure) {
|
||||||
|
setStructure(pageData.structure);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
}
|
||||||
|
}, [pageData]);
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Page saved successfully'));
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['page-structure'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(__('Failed to save page'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new section
|
||||||
|
const handleAddSection = (sectionType: string) => {
|
||||||
|
if (!structure) {
|
||||||
|
setStructure({
|
||||||
|
type: selectedPage?.type || 'page',
|
||||||
|
sections: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const newSection: Section = {
|
||||||
|
id: `section-${Date.now()}`,
|
||||||
|
type: sectionType,
|
||||||
|
layoutVariant: 'default',
|
||||||
|
colorScheme: 'default',
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
setStructure(prev => ({
|
||||||
|
...prev!,
|
||||||
|
sections: [...(prev?.sections || []), newSection],
|
||||||
|
}));
|
||||||
|
setSelectedSection(newSection);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
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 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-64px)] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 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')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<span className="text-sm text-amber-600">{__('Unsaved changes')}</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
{__('Create Page')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={!hasUnsavedChanges || saveMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? __('Saving...') : __('Save Changes')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3-Column Layout */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Left Column: Pages List */}
|
||||||
|
<PageSidebar
|
||||||
|
pages={pages}
|
||||||
|
selectedPage={selectedPage}
|
||||||
|
onSelectPage={(page) => {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
if (!confirm(__('You have unsaved changes. Continue?'))) return;
|
||||||
|
}
|
||||||
|
setSelectedPage(page);
|
||||||
|
setSelectedSection(null);
|
||||||
|
}}
|
||||||
|
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}
|
||||||
|
onSelectSection={setSelectedSection}
|
||||||
|
onAddSection={handleAddSection}
|
||||||
|
onDeleteSection={handleDeleteSection}
|
||||||
|
onMoveSection={handleMoveSection}
|
||||||
|
isTemplate={selectedPage.type === 'template'}
|
||||||
|
cpt={selectedPage.cpt}
|
||||||
|
isLoading={pageLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Settings & Preview */}
|
||||||
|
<PageSettings
|
||||||
|
page={selectedPage}
|
||||||
|
section={selectedSection}
|
||||||
|
onSectionUpdate={handleSectionUpdate}
|
||||||
|
isTemplate={selectedPage?.type === 'template'}
|
||||||
|
availableSources={pageData?.available_sources || []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Page Modal */}
|
||||||
|
<CreatePageModal
|
||||||
|
open={showCreateModal}
|
||||||
|
onOpenChange={setShowCreateModal}
|
||||||
|
onCreated={(newPage) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
|
setSelectedPage(newPage);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user