import React, { useState, useEffect, useCallback, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import axios from 'axios' import Pusher from 'pusher-js' import { Layers, Settings, Trash2, Plus, ChevronUp, ChevronDown, ArrowLeft, Loader2, Eye, Palette, Globe, FileEdit, FileText, ToggleLeft, ToggleRight, } from 'lucide-react' import BlockRenderer, { BLOCK_TYPES, BLOCK_TYPE_LABELS } from './BlockRenderer' import BlockStyleEditor from './BlockStyleEditor' import GlobalStylesPanel from './GlobalStylesPanel' const BASE_URL = import.meta.env.VITE_API_BASE_URL // ─── Sidebar tab definitions ──────────────────────────────────────────────── const TABS = [ { id: 'pages', icon: FileText, label: 'Pages' }, { id: 'blocks', icon: Layers, label: 'Blocks' }, { id: 'styles', icon: Palette, label: 'Styles' }, { id: 'lang', icon: Globe, label: 'Lang' }, ] export default function PageEditor() { const { pageId } = useParams() const navigate = useNavigate() const [page, setPage] = useState(null) const [blocks, setBlocks] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(null) const [selectedBlockId, setSelectedBlockId] = useState(null) const [activeTab, setActiveTab] = useState('blocks') const [leftPanel, setLeftPanel] = useState('blocks') // 'blocks' | 'styles' | 'content' | 'lang' const [toast, setToast] = useState(null) const [currentPageId, setCurrentPageId] = useState(null); const selectedBlock = blocks.find(b => b.id === selectedBlockId) || null const blockRefs = useRef({}) // ─── Load page ────────────────────────────────────────────────────────── useEffect(() => { fetchPage() }, [pageId]) // ─── Pusher live updates ───────────────────────────────────────────────── useEffect(() => { const pusher = new Pusher(import.meta.env.VITE_PUSHER_APP_KEY, { cluster: import.meta.env.VITE_PUSHER_CLUSTER || 'ap2', forceTLS: true, }) const channel = pusher.subscribe('page-builder-channel') channel.bind('page-updated', (data) => { if (data.page_slug === page?.slug) { setBlocks(data.blocks || []) showToast('Page updated in another session', 'info') } }) return () => { channel.unsubscribe(); pusher.disconnect() } }, [page?.slug]) const fetchPage = async () => { setLoading(true) let id = pageId || currentPageId console.log('[PE:fetchPage] triggered — pageId from URL:', pageId, '| currentPageId:', currentPageId, '| resolved id:', id) console.log('[PE:fetchPage] BASE_URL:', BASE_URL) try { if (!id || id === 'undefined') { console.log('[PE:fetchPage] no id found → fetching pages list for redirect') const res = await axios.get(`${BASE_URL}/admin/builder/pages`) console.log('[PE:fetchPage] pages list response status:', res.status, '| raw data:', res.data) const pages = res.data?.data ?? res.data ?? [] if (!pages.length) throw new Error('No pages found') const target = pages.find(p => p.slug === 'homepage') || pages[0] id = target.id console.log('[PE:fetchPage] redirecting to page:', id, target.slug) setCurrentPageId(id) navigate(`/admin/editor/${id}`, { replace: true }) return } console.log('[PE:fetchPage] fetching page id:', id, '| URL:', `${BASE_URL}/admin/builder/pages/${id}`) const res = await axios.get(`${BASE_URL}/admin/builder/pages/${id}`) console.log('[PE:fetchPage] response status:', res.status, '| raw data:', res.data) const data = res.data?.data ?? res.data console.log('[PE:fetchPage] resolved page:', data, '| blocks count:', data?.blocks?.length ?? 0) setPage(data) setCurrentPageId(data.id) setBlocks(data.blocks || []) } catch (err) { console.error('[PE:fetchPage] ERROR:', err.message) console.error('[PE:fetchPage] response status:', err.response?.status, '| body:', err.response?.data) showToast('Failed to load page', 'error') } finally { setLoading(false) console.log('[PE:fetchPage] done') } } // ─── Add block ─────────────────────────────────────────────────────────── const addBlock = async (blockType) => { const id = currentPageId console.log('[PE:addBlock] blockType:', blockType, '| currentPageId:', id) if (!id || id === 'undefined') { console.warn('[PE:addBlock] aborted — page not loaded yet') showToast('Page not loaded yet', 'error') return } try { const url = `${BASE_URL}/admin/builder/pages/${id}/blocks` console.log('[PE:addBlock] POST', url, { block_type: blockType }) const res = await axios.post(url, { block_type: blockType }) console.log('[PE:addBlock] response status:', res.status, '| new block:', res.data) const newBlock = res.data?.data ?? res.data setBlocks(prev => [...prev, newBlock]) showToast('Block added', 'success') } catch (err) { console.error('[PE:addBlock] ERROR:', err.message) console.error('[PE:addBlock] response status:', err.response?.status, '| body:', err.response?.data) showToast('Failed to add block', 'error') } } // ─── Save block styles ─────────────────────────────────────────────────── const saveBlockStyles = useCallback(async (blockId, styles) => { console.log('[PE:saveBlockStyles] blockId:', blockId, '| styles:', styles) setSaving(blockId) try { const url = `${BASE_URL}/admin/builder/blocks/${blockId}` console.log('[PE:saveBlockStyles] PUT', url) const res = await axios.put(url, { styles }) console.log('[PE:saveBlockStyles] saved — status:', res.status) setBlocks(prev => prev.map(b => b.id === blockId ? { ...b, styles } : b)) } catch (err) { console.error('[PE:saveBlockStyles] ERROR:', err.message) console.error('[PE:saveBlockStyles] response status:', err.response?.status, '| body:', err.response?.data) showToast('Failed to save styles', 'error') } finally { setSaving(null) } }, []) // ─── Save block content ────────────────────────────────────────────────── const saveBlockContent = useCallback(async (blockId, content) => { console.log('[PE:saveBlockContent] blockId:', blockId, '| content:', content) setSaving(blockId) try { const url = `${BASE_URL}/admin/builder/blocks/${blockId}` console.log('[PE:saveBlockContent] PUT', url) const res = await axios.put(url, { content }) console.log('[PE:saveBlockContent] saved — status:', res.status) setBlocks(prev => prev.map(b => b.id === blockId ? { ...b, content } : b)) } catch (err) { console.error('[PE:saveBlockContent] ERROR:', err.message) console.error('[PE:saveBlockContent] response status:', err.response?.status, '| body:', err.response?.data) showToast('Failed to save content', 'error') } finally { setSaving(null) } }, []) // ─── Delete block ──────────────────────────────────────────────────────── const deleteBlock = async (blockId) => { if (!window.confirm('Delete this block?')) return console.log('[PE:deleteBlock] deleting blockId:', blockId) try { const url = `${BASE_URL}/admin/builder/blocks/${blockId}` console.log('[PE:deleteBlock] DELETE', url) const res = await axios.delete(url) console.log('[PE:deleteBlock] deleted — status:', res.status) setBlocks(prev => prev.filter(b => b.id !== blockId)) if (selectedBlockId === blockId) setSelectedBlockId(null) showToast('Block deleted', 'success') } catch (err) { console.error('[PE:deleteBlock] ERROR:', err.message) console.error('[PE:deleteBlock] response status:', err.response?.status, '| body:', err.response?.data) showToast('Failed to delete block', 'error') } } // ─── Reorder block ─────────────────────────────────────────────────────── const moveBlock = async (index, direction) => { const newIndex = direction === 'up' ? index - 1 : index + 1 console.log('[PE:moveBlock] index:', index, '| direction:', direction, '| newIndex:', newIndex) if (newIndex < 0 || newIndex >= blocks.length) { console.log('[PE:moveBlock] out of bounds — aborting') return } const reordered = [...blocks] const [moved] = reordered.splice(index, 1) reordered.splice(newIndex, 0, moved) const withOrder = reordered.map((b, i) => ({ ...b, sort_order: i })) setBlocks(withOrder) console.log('[PE:moveBlock] new order:', withOrder.map(b => ({ id: b.id, sort_order: b.sort_order }))) try { const url = `${BASE_URL}/admin/builder/pages/${page?.id}/blocks/reorder` console.log('[PE:moveBlock] POST', url) const res = await axios.post(url, { order: withOrder.map(b => ({ id: b.id, sort_order: b.sort_order })), }) console.log('[PE:moveBlock] reordered — status:', res.status) } catch (err) { console.error('[PE:moveBlock] ERROR:', err.message) console.error('[PE:moveBlock] response status:', err.response?.status, '| body:', err.response?.data) showToast('Failed to reorder', 'error') fetchPage() } } // ─── Style / content change handlers ──────────────────────────────────── const handleStyleChange = (styles) => { if (!selectedBlockId) return console.log('[PE:handleStyleChange] blockId:', selectedBlockId, '| styles:', styles) setBlocks(prev => prev.map(b => b.id === selectedBlockId ? { ...b, styles } : b)) clearTimeout(window._styleTimer) window._styleTimer = setTimeout(() => saveBlockStyles(selectedBlockId, styles), 600) } const handleContentChange = (content) => { if (!selectedBlockId) return console.log('[PE:handleContentChange] blockId:', selectedBlockId, '| content:', content) setBlocks(prev => prev.map(b => b.id === selectedBlockId ? { ...b, content } : b)) clearTimeout(window._contentTimer) window._contentTimer = setTimeout(() => saveBlockContent(selectedBlockId, content), 600) } const showToast = (message, type = 'success') => { setToast({ message, type }) setTimeout(() => setToast(null), 3000) } // ─── Tab click ─────────────────────────────────────────────────────────── const handleTabClick = (tabId) => { setActiveTab(tabId) setLeftPanel(tabId) if (tabId !== 'content' && tabId !== 'block-styles') { setSelectedBlockId(null) } } // ─── Block selected — open content panel ───────────────────────────────── const selectBlock = (blockId) => { setSelectedBlockId(blockId) setLeftPanel('content') } // ─── Loading ────────────────────────────────────────────────────────────── if (loading) { return (
) } // ─── Render ─────────────────────────────────────────────────────────────── return (
{/* ── Top bar ── */}
{page?.title || 'Page Editor'} /{page?.slug}
Preview
{/* ── Main area ── */}
{/* ── Icon nav (56px) ── */}
{TABS.map(({ id, icon: Icon, label }) => ( ))}
{/* ── Left panel (280px) ── */}
{leftPanel === 'pages' && ( )} {leftPanel === 'blocks' && ( )} {leftPanel === 'styles' && ( )} {leftPanel === 'content' && selectedBlock && ( setLeftPanel('block-styles')} onClose={() => { setLeftPanel('blocks'); setSelectedBlockId(null) }} /> )} {leftPanel === 'block-styles' && selectedBlock && (
Edit Styles
setLeftPanel('content')} embedded />
)} {leftPanel === 'lang' && ( )} {/* Fallback when nothing selected */} {leftPanel === 'content' && !selectedBlock && ( )}
{/* ── Canvas ── */}
{ if (e.target === e.currentTarget) { setSelectedBlockId(null) setLeftPanel(activeTab) } }} > {blocks.length === 0 && (

No blocks yet.

Click a block type in the left panel to add one.

)} {blocks.map((block, index) => (
{ // Insert at position — for now just adds at end // Future: implement cursor-based insert }} />
{ blockRefs.current[block.id] = el }} className="relative group cursor-pointer" onClick={() => selectBlock(block.id)} style={{ outline: selectedBlockId === block.id ? '2px solid #6366f1' : '2px solid transparent', outlineOffset: '-2px', transition: 'outline 0.15s', }} > moveBlock(index, 'up')} onMoveDown={() => moveBlock(index, 'down')} onEditContent={() => selectBlock(block.id)} onEditStyles={() => { selectBlock(block.id); setLeftPanel('block-styles') }} onDelete={() => deleteBlock(block.id)} />
{index === blocks.length - 1 && ( {}} /> )}
))}
{/* Toast */} {toast && (
{toast.message}
)}
) } // ─── Pages Panel ───────────────────────────────────────────────────────────── function PagesPanel({ currentPageId }) { const navigate = useNavigate() const [pages, setPages] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { const fetchPages = async () => { setLoading(true) setError(null) const url = `${BASE_URL}/admin/builder/pages` console.log('[PE:PagesPanel] fetching pages list | URL:', url) try { const res = await axios.get(url) console.log('[PE:PagesPanel] response status:', res.status, '| raw data:', res.data) const data = res.data?.data ?? res.data ?? [] const list = Array.isArray(data) ? data : [] console.log('[PE:PagesPanel] resolved', list.length, 'pages:', list.map(p => ({ id: p.id, slug: p.slug }))) setPages(list) } catch (err) { console.error('[PE:PagesPanel] ERROR:', err.message) console.error('[PE:PagesPanel] response status:', err.response?.status, '| body:', err.response?.data) setError('Failed to load pages') } finally { setLoading(false) console.log('[PE:PagesPanel] done') } } fetchPages() }, []) return (
{/* Header */}
Pages
{/* Body */}
{loading && (
)} {error && !loading && (
{error}
)} {!loading && !error && pages.length === 0 && (
No pages found.
)} {!loading && !error && pages.map(page => { const isActive = String(page.id) === String(currentPageId) return ( ) })}
) } // ─── Blocks Panel ──────────────────────────────────────────────────────────── function BlocksPanel({ onAdd }) { return (
Blocks
{BLOCK_TYPES.map(({ value, label }) => ( ))}
) } // ─── Block Content Panel ────────────────────────────────────────────────────── function BlockContentPanel({ block, isSaving, onChange, onStylesOpen, onClose }) { const [content, setContent] = useState(block.content || {}) // Sync if block changes useEffect(() => { setContent(block.content || {}) }, [block.id]) const update = (key, value) => { const updated = { ...content, [key]: value } setContent(updated) onChange(updated) } const updateItem = (index, key, value) => { const items = [...(content.items || [])] items[index] = { ...items[index], [key]: value } update('items', items) } const fields = { hero: ['title', 'subtitle', 'button_text', 'button_link'], cta: ['title', 'subtitle', 'button_text', 'button_link'], text_section: ['title', 'body'], } return (
{/* Header */}
{BLOCK_TYPE_LABELS[block.block_type] || block.block_type} {isSaving && Saving…}
{/* Tabs: Content | Styles */}
{/* Fields */}
{/* Simple text fields */} {(fields[block.block_type] || []).map(key => (
{key === 'body' ? (