import React, { useCallback, useState, useEffect, useRef } from 'react'; import { useEditor } from '@craftjs/core'; import { useEditorConfig } from '../../state/EditorConfigContext'; import { useWhpApi } from '../../hooks/useWhpApi'; import { usePages } from '../../state/PageContext'; import { useSiteDesign } from '../../state/SiteDesignContext'; import { useIsMobile } from '../../hooks/useIsMobile'; import { useMobileChrome } from '../../state/MobileChromeContext'; import { DeviceMode } from '../../types'; import { TemplateModal } from './TemplateModal'; import { HeadCodeModal } from './HeadCodeModal'; import { TopBarOverflowMenu } from './TopBarOverflowMenu'; import { SitesmithButton } from '../sitesmith/SitesmithButton'; import { useSitesmithModal } from '../../state/SitesmithContext'; interface TopBarProps { device: DeviceMode; onDeviceChange: (device: DeviceMode) => void; /** Item 10: canvas dashed guide outlines, default ON. */ showGuides: boolean; onToggleGuides: () => void; } export const TopBar: React.FC = ({ device, onDeviceChange, showGuides, onToggleGuides }) => { const { whpConfig, isWHP } = useEditorConfig(); const { actions, query, canUndo, canRedo } = useEditor((_state, query) => ({ canUndo: query.history.canUndo(), canRedo: query.history.canRedo(), })); const { save, publish, load } = useWhpApi(); const { headerPage, footerPage } = usePages(); const { design } = useSiteDesign(); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle'); const [isDraft, setIsDraft] = useState(false); // Mobile-A2: lifted from private useState into MobileChromeContext so // opening a mobile sheet can close these modals (item 3) -- behavior is // otherwise identical for both the desktop and mobile branches below. const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome(); const isMobile = useIsMobile(); const { open: openSitesmith } = useSitesmithModal(); const saveTimeoutRef = useRef | null>(null); const publishTimeoutRef = useRef | null>(null); const hasLoadedRef = useRef(false); // Load saved state on mount useEffect(() => { if (!isWHP || hasLoadedRef.current) return; hasLoadedRef.current = true; load().catch((e) => { console.warn('Failed to load project from WHP API:', e); }); }, [isWHP, load]); // Auto-save every 30 seconds useEffect(() => { if (!isWHP) return; const interval = setInterval(() => { save() .then((result) => { if (result?.success) { setSaveStatus('saved'); setIsDraft(true); if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2000); } }) .catch(() => { // Silent fail for auto-save }); }, 30000); return () => clearInterval(interval); }, [isWHP, save]); const handleSave = useCallback(async () => { setSaveStatus('saving'); try { const result = await save(); if (result?.success) { setSaveStatus('saved'); setIsDraft(true); if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2500); } else { setSaveStatus('error'); if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000); } } catch (e) { console.error('Save failed:', e); setSaveStatus('error'); if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000); } }, [save]); const handlePublish = useCallback(async () => { setPublishStatus('publishing'); try { const result = await publish(); if (result?.success) { setPublishStatus('published'); setIsDraft(false); if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current); publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000); } else { setPublishStatus('error'); if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current); publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000); } } catch (e) { console.error('Publish failed:', e); setPublishStatus('error'); if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current); publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000); } }, [publish]); // Cleanup timeouts on unmount useEffect(() => { return () => { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current); }; }, []); // Extracted so both the desktop inline Preview button and the mobile // overflow-menu "Preview" item can trigger the exact same flow. const handlePreview = useCallback(() => { try { const serialized = query.serialize(); import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => { // Get header HTML let headerHtml = ''; try { if (headerPage.craftState) { headerHtml = exportBodyHtml(headerPage.craftState).html; } } catch (e) { console.warn('Header export failed:', e); } // Get page body HTML const bodyResult = exportBodyHtml(serialized); const bodyHtml = bodyResult.html; // Get footer HTML let footerHtml = ''; try { if (footerPage.craftState) { footerHtml = exportBodyHtml(footerPage.craftState).html; } } catch (e) { console.warn('Footer export failed:', e); } // Compose full page: header + body + footer const composedBody = headerHtml + bodyHtml + footerHtml; const result = exportToHtml(serialized, { title: whpConfig?.siteName || 'Preview', includeFonts: true, headCode: design.headCode, }); // Replace the body in the full document with our composed version. // Use a function replacer -- a plain string replacer treats // `$&`/`$1`/`$$` sequences in user content as replacement // patterns, silently corrupting the output. let html = result.html; const bodyMatch = html.match(/]*>([\s\S]*)<\/body>/i); if (bodyMatch) { html = html.replace(bodyMatch[1], () => composedBody); } // Make proxy URLs absolute so they work from the blob: context const origin = window.location.origin; html = html.replace(/src="\/api\//g, `src="${origin}/api/`); html = html.replace(/url\('\/api\//g, `url('${origin}/api/`); const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); window.open(url, '_blank'); }); } catch (e) { console.error('Preview failed:', e); } }, [query, headerPage, footerPage, whpConfig, design]); // Phase A: at ≤768px the topbar collapses to only the essentials -- // back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith, // Preview, Head Code, the guides toggle, and the device switcher moved // into the "⋯" overflow menu. This keeps desktop's JSX/behavior below // completely untouched. if (isMobile) { return ( ); } return ( ); };