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:
Dwindi Ramadhana
2026-01-11 22:44:00 +07:00
parent 749cfb3f92
commit bdded61221
6 changed files with 1051 additions and 0 deletions

View File

@@ -261,6 +261,7 @@ import AppearanceCart from '@/routes/Appearance/Cart';
import AppearanceCheckout from '@/routes/Appearance/Checkout';
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
import AppearanceAccount from '@/routes/Appearance/Account';
import AppearancePages from '@/routes/Appearance/Pages';
import MarketingIndex from '@/routes/Marketing';
import Newsletter from '@/routes/Marketing/Newsletter';
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
@@ -608,6 +609,7 @@ function AppRoutes() {
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
<Route path="/appearance/account" element={<AppearanceAccount />} />
<Route path="/appearance/pages" element={<AppearancePages />} />
{/* Marketing */}
<Route path="/marketing" element={<MarketingIndex />} />

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}