2026-04-05 18:31:16 -07:00
|
|
|
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 { DeviceMode } from '../../types';
|
|
|
|
|
import { TemplateModal } from './TemplateModal';
|
|
|
|
|
import { HeadCodeModal } from './HeadCodeModal';
|
2026-05-23 14:23:51 -07:00
|
|
|
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
interface TopBarProps {
|
|
|
|
|
device: DeviceMode;
|
|
|
|
|
onDeviceChange: (device: DeviceMode) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|
|
|
|
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 [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
|
|
|
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
|
|
|
|
|
const [isDraft, setIsDraft] = useState(false);
|
|
|
|
|
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
|
|
|
|
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
|
2026-05-23 14:23:51 -07:00
|
|
|
const [sitesmithOpen, setSitesmithOpen] = useState(false);
|
2026-04-05 18:31:16 -07:00
|
|
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<nav className="topbar">
|
|
|
|
|
<div className="topbar-left">
|
|
|
|
|
{isWHP && (
|
|
|
|
|
<a href={whpConfig!.backUrl} className="topbar-btn back-btn">
|
|
|
|
|
<i className="fa fa-arrow-left" /> Back to Panel
|
|
|
|
|
</a>
|
|
|
|
|
)}
|
|
|
|
|
<span className="topbar-title">Site Builder</span>
|
|
|
|
|
{isWHP && (
|
|
|
|
|
<span className="topbar-domain">{whpConfig!.siteDomain}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="topbar-center">
|
|
|
|
|
<div className="device-switcher">
|
|
|
|
|
{(['desktop', 'tablet', 'mobile'] as DeviceMode[]).map((d) => (
|
|
|
|
|
<button
|
|
|
|
|
key={d}
|
|
|
|
|
className={`device-btn ${device === d ? 'active' : ''}`}
|
|
|
|
|
onClick={() => onDeviceChange(d)}
|
|
|
|
|
title={d.charAt(0).toUpperCase() + d.slice(1)}
|
|
|
|
|
>
|
|
|
|
|
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="topbar-right">
|
|
|
|
|
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo">
|
|
|
|
|
<i className="fa fa-undo" />
|
|
|
|
|
</button>
|
|
|
|
|
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo">
|
|
|
|
|
<i className="fa fa-repeat" />
|
|
|
|
|
</button>
|
|
|
|
|
<span className="topbar-divider" />
|
|
|
|
|
<button className="topbar-btn" title="Templates" onClick={() => setTemplateModalOpen(true)}>
|
|
|
|
|
<i className="fa fa-th-large" /> Templates
|
|
|
|
|
</button>
|
|
|
|
|
<button className="topbar-btn" title="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}>
|
|
|
|
|
<i className="fa fa-code" /> Code
|
|
|
|
|
</button>
|
|
|
|
|
<button className="topbar-btn" title="Preview" onClick={() => {
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Replace the body in the full document with our composed version
|
|
|
|
|
let html = result.html;
|
|
|
|
|
const bodyMatch = html.match(/<body[^>]*>([\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);
|
|
|
|
|
}
|
|
|
|
|
}}>
|
|
|
|
|
<i className="fa fa-eye" /> Preview
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Draft/Published status badge */}
|
|
|
|
|
{isWHP && isDraft && publishStatus !== 'published' && (
|
|
|
|
|
<span className="publish-badge draft">
|
|
|
|
|
<i className="fa fa-pencil" /> Draft
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{publishStatus === 'published' && (
|
|
|
|
|
<span className="publish-badge published">
|
|
|
|
|
<i className="fa fa-check-circle" /> Published
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Save status indicator */}
|
|
|
|
|
{saveStatus === 'saved' && (
|
|
|
|
|
<span className="save-indicator saved">
|
|
|
|
|
<i className="fa fa-check" /> Saved!
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{saveStatus === 'error' && (
|
|
|
|
|
<span className="save-indicator error">
|
|
|
|
|
<i className="fa fa-exclamation-triangle" /> Save Error
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{publishStatus === 'error' && (
|
|
|
|
|
<span className="save-indicator error">
|
|
|
|
|
<i className="fa fa-exclamation-triangle" /> Publish Error
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-23 14:23:51 -07:00
|
|
|
<SitesmithButton onClick={() => setSitesmithOpen(true)} />
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
<button
|
|
|
|
|
className="topbar-btn primary"
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={saveStatus === 'saving'}
|
|
|
|
|
title="Save Draft"
|
|
|
|
|
>
|
|
|
|
|
{saveStatus === 'saving' ? (
|
|
|
|
|
<><i className="fa fa-spinner fa-spin" /> Saving...</>
|
|
|
|
|
) : (
|
|
|
|
|
<><i className="fa fa-save" /> Save</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{isWHP && (
|
|
|
|
|
<button
|
|
|
|
|
className="topbar-btn publish"
|
|
|
|
|
onClick={handlePublish}
|
|
|
|
|
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
|
|
|
|
title="Publish to live site"
|
|
|
|
|
>
|
|
|
|
|
{publishStatus === 'publishing' ? (
|
|
|
|
|
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
|
|
|
|
|
) : (
|
|
|
|
|
<><i className="fa fa-globe" /> Publish</>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
|
|
|
|
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
2026-05-23 14:23:51 -07:00
|
|
|
{sitesmithOpen && (
|
|
|
|
|
<div role="dialog" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', zIndex: 9000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
|
|
|
<div style={{ background: '#0f0f17', color: '#fff', padding: 32, borderRadius: 10 }}>
|
|
|
|
|
<p>SitesmithModal will land in Task 20.</p>
|
|
|
|
|
<button onClick={() => setSitesmithOpen(false)} style={{ background: '#7c3aed', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6 }}>Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-05 18:31:16 -07:00
|
|
|
</nav>
|
|
|
|
|
);
|
|
|
|
|
};
|