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