2a8a26687b
- Force font-size:16px !important on Styles-sheet/topbar/Sitesmith inputs inside the mobile media query so inline 12px/14px styles stop triggering iOS zoom-on-focus. - Lift sheet-open + Templates/Head Code modal-open state out of private useState into a shared MobileChromeContext (EditorShell), so Phase B can open/close sheets from outside MobilePanelBar. - Add an explicit z-index layer scale, portal TemplateModal to document.body (was trapped under the tab bar inside .topbar's stacking context), align Sitesmith to the same --z-modal layer, and make opening a sheet close any open modal. Also fix modal backdrops swallowing tab bar taps (mirrors the sheet backdrop's existing tab-bar cutout). - Drop BottomSheet's incorrect aria-modal; mobile-aware AssetsPanel empty state copy. - Tests: useIsMobile (matchMedia mock incl. legacy fallback + cleanup), MobileChromeContext invariants (one sheet open, sheet closes modals), MobilePanelBar wiring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
588 lines
20 KiB
TypeScript
588 lines
20 KiB
TypeScript
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useEditor } from '@craftjs/core';
|
|
import { usePages } from '../../state/PageContext';
|
|
import { useSiteDesign } from '../../state/SiteDesignContext';
|
|
import {
|
|
allTemplates,
|
|
TemplateDefinition,
|
|
TemplateComponent,
|
|
TemplateCategory,
|
|
} from '../../templates';
|
|
import { clickableProps } from '../../utils/a11y';
|
|
import { Modal } from '../../ui/Modal';
|
|
import { buildNodeTree } from '../../utils/craft-tree';
|
|
import { templateComponentToTreeNode } from '../../templates/apply-template';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TemplateModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
type FilterTab = 'all' | TemplateCategory;
|
|
|
|
const TABS: { label: string; value: FilterTab }[] = [
|
|
{ label: 'All', value: 'all' },
|
|
{ label: 'Business', value: 'business' },
|
|
{ label: 'Creative', value: 'creative' },
|
|
{ label: 'Personal', value: 'personal' },
|
|
{ label: 'Community', value: 'community' },
|
|
];
|
|
|
|
const CATEGORY_COLORS: Record<TemplateCategory, string> = {
|
|
business: '#3b82f6',
|
|
creative: '#a855f7',
|
|
personal: '#f59e0b',
|
|
community: '#10b981',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Modal Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) => {
|
|
const { actions, query } = useEditor();
|
|
const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages();
|
|
const { updateDesign } = useSiteDesign();
|
|
|
|
const [activeTab, setActiveTab] = useState<FilterTab>('all');
|
|
const [confirmTemplate, setConfirmTemplate] = useState<TemplateDefinition | null>(null);
|
|
const [applyDesign, setApplyDesign] = useState(true);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// Guards state updates after the component has unmounted mid-sequence.
|
|
const mountedRef = useRef(true);
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => {
|
|
mountedRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
// Escape / backdrop-click close the confirmation dialog first if it's open,
|
|
// otherwise close the modal. Passed to <Modal> below, which handles the
|
|
// Escape listener and the backdrop-click detection itself; the header's own
|
|
// close button (X) below still uses the raw `onClose` prop directly, so it
|
|
// always fully closes the modal even mid-confirmation (unchanged behavior).
|
|
const handleModalClose = useCallback(() => {
|
|
if (confirmTemplate) setConfirmTemplate(null);
|
|
else onClose();
|
|
}, [confirmTemplate, onClose]);
|
|
|
|
// Filter templates by category
|
|
const filtered = useMemo(() => {
|
|
if (activeTab === 'all') return allTemplates;
|
|
return allTemplates.filter((t) => t.category === activeTab);
|
|
}, [activeTab]);
|
|
|
|
/**
|
|
* Add all components from a template definition onto the current (empty) canvas ROOT.
|
|
* Converts each `TemplateComponent` (the plain `{type, props, children?}`
|
|
* shape templates author) to a `SerializedTreeNode` and runs it through
|
|
* `buildNodeTree` (sanitize -> flatten -> materialize), the same tree pipeline
|
|
* already used for AI-generated content. This recurses into nested
|
|
* `children` (e.g. a header/footer `Container` wrapping `Logo`/`Menu`, or a
|
|
* page `Section` wrapping a `Heading`) AND correctly routes them through a
|
|
* component's SHELL_INNER linked canvas / linked columns where needed --
|
|
* see `templates/apply-template.ts` for the two bugs this fixes (dropped
|
|
* children, and a naive `parseReactElement` tree crashing `Section`/
|
|
* `BackgroundSection`/`FormContainer`/`ColumnLayout`).
|
|
*/
|
|
const addTemplateComponents = useCallback(
|
|
(components: TemplateComponent[]) => {
|
|
for (const comp of components) {
|
|
try {
|
|
const tree = buildNodeTree(query, templateComponentToTreeNode(comp));
|
|
actions.addNodeTree(tree, 'ROOT');
|
|
} catch (e) {
|
|
console.warn(`Failed to build template component tree for type "${comp.type}":`, e);
|
|
}
|
|
}
|
|
},
|
|
[query, actions],
|
|
);
|
|
|
|
/**
|
|
* Clear the current canvas by deleting all children of ROOT.
|
|
*/
|
|
const clearCanvas = useCallback(() => {
|
|
try {
|
|
const rootNode = query.node('ROOT').get();
|
|
const childIds = [...(rootNode.data.nodes || [])];
|
|
childIds.forEach((id) => {
|
|
try {
|
|
actions.delete(id);
|
|
} catch {
|
|
// Node may already be removed
|
|
}
|
|
});
|
|
} catch {
|
|
// ROOT doesn't exist yet or is empty -- that's fine
|
|
}
|
|
}, [query, actions]);
|
|
|
|
/** Promise-based `setTimeout` — lets the staged apply steps below be awaited
|
|
* in sequence instead of chained via nested callbacks. */
|
|
const wait = useCallback((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)), []);
|
|
|
|
/**
|
|
* Switch to a zone (header/footer), clear it, and add the template's
|
|
* components, giving Craft.js a tick to settle between each step.
|
|
*/
|
|
const applyZone = useCallback(
|
|
async (switchFn: () => void, components: TemplateComponent[]) => {
|
|
switchFn();
|
|
await wait(30);
|
|
try {
|
|
clearCanvas();
|
|
await wait(20);
|
|
try {
|
|
addTemplateComponents(components);
|
|
} catch (e) {
|
|
console.warn('Failed to add zone components:', e);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to clear zone:', e);
|
|
}
|
|
await wait(30);
|
|
},
|
|
[wait, clearCanvas, addTemplateComponents],
|
|
);
|
|
|
|
/**
|
|
* After all pages are loaded, apply header and footer template content.
|
|
* Switches to header/footer editing mode, clears, adds components, then returns to firstPageId.
|
|
*/
|
|
const applyHeaderFooter = useCallback(
|
|
async (tpl: TemplateDefinition, firstPageId: string) => {
|
|
const hasHeader = tpl.header?.components?.length > 0;
|
|
const hasFooter = tpl.footer?.components?.length > 0;
|
|
|
|
if (hasHeader) await applyZone(editHeader, tpl.header.components);
|
|
if (hasFooter) await applyZone(editFooter, tpl.footer.components);
|
|
|
|
// Switch back to the first page
|
|
switchPage(firstPageId);
|
|
await wait(30);
|
|
},
|
|
[applyZone, switchPage, editHeader, editFooter, wait],
|
|
);
|
|
|
|
// Load the selected template. Awaits the full staged sequence (clear, add
|
|
// components, add pages, apply header/footer) before closing the modal, so
|
|
// "Loading…" stays visible for the duration and onClose() never fires
|
|
// while the sequence is still mutating a canvas the dialog just tore down.
|
|
const handleLoad = useCallback(async () => {
|
|
if (!confirmTemplate) return;
|
|
setLoading(true);
|
|
|
|
const tpl = confirmTemplate;
|
|
setConfirmTemplate(null);
|
|
|
|
try {
|
|
// 1. Optionally apply design tokens
|
|
if (applyDesign && tpl.design) {
|
|
updateDesign(tpl.design);
|
|
}
|
|
|
|
// 2. Remove all pages except the first
|
|
const currentPages = [...pages];
|
|
const keepId = currentPages[0]?.id;
|
|
for (let i = currentPages.length - 1; i >= 1; i--) {
|
|
deletePage(currentPages[i].id);
|
|
}
|
|
|
|
// 3. Clear the current canvas and add the first template page's components
|
|
clearCanvas();
|
|
// Short delay to let the clear settle before adding new nodes
|
|
await wait(20);
|
|
|
|
const firstPage = tpl.pages[0];
|
|
addTemplateComponents(firstPage.content.components);
|
|
|
|
// 4. Add remaining pages (if multi-page template)
|
|
if (tpl.pages.length > 1) {
|
|
await wait(30);
|
|
for (let pageIndex = 1; pageIndex < tpl.pages.length; pageIndex++) {
|
|
const pageDef = tpl.pages[pageIndex];
|
|
addPage(pageDef.name, pageDef.slug);
|
|
|
|
// After addPage, the new page is active with an empty canvas.
|
|
// Give a tick for Craft.js to settle, then add components.
|
|
await wait(30);
|
|
try {
|
|
addTemplateComponents(pageDef.content.components);
|
|
} catch (e) {
|
|
console.warn('Failed to add components for page', pageDef.name, e);
|
|
}
|
|
await wait(30);
|
|
}
|
|
|
|
// Switch back to the first page before applying header/footer
|
|
if (keepId) switchPage(keepId);
|
|
await wait(30);
|
|
}
|
|
|
|
// 5. Apply header and footer template content
|
|
if (keepId) {
|
|
await applyHeaderFooter(tpl, keepId);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load template:', e);
|
|
} finally {
|
|
if (mountedRef.current) setLoading(false);
|
|
onClose();
|
|
}
|
|
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose]);
|
|
|
|
// Mobile-A2: portaled to `document.body` -- TopBar.tsx mounts this modal
|
|
// as a child of `.topbar`, which (as a flex item with its own z-index)
|
|
// forms its own stacking context. That trapped the modal's fixed-position
|
|
// backdrop underneath the mobile tab bar (z-index: var(--z-tabbar)) no
|
|
// matter how high the modal's own z-index was set. Portaling escapes that
|
|
// stacking context entirely so `--z-modal` (see editor.css) is evaluated
|
|
// at the document root, same as SitesmithModal.
|
|
return createPortal(
|
|
<Modal open={open} onClose={handleModalClose}>
|
|
<div style={modalStyle}>
|
|
{/* Header */}
|
|
<div style={modalHeaderStyle}>
|
|
<div>
|
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#e4e4e7' }}>
|
|
Templates
|
|
</h2>
|
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#71717a' }}>
|
|
Choose a template to get started quickly
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="template-modal-close-btn"
|
|
style={closeButtonStyle}
|
|
title="Close"
|
|
aria-label="Close"
|
|
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text)'; }}
|
|
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent'; (e.currentTarget as HTMLElement).style.color = '#71717a'; }}
|
|
>
|
|
<i className="fa fa-times" aria-hidden />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter Tabs -- horizontally scrollable (tabBarStyle sets
|
|
overflowX: 'auto') so the row never wraps/overflows the modal at
|
|
narrow widths; template-modal-tab-btn below keeps each tab from
|
|
shrinking below a comfortable touch target on mobile. */}
|
|
<div className="template-modal-tabbar" style={tabBarStyle}>
|
|
{TABS.map((tab) => (
|
|
<button
|
|
key={tab.value}
|
|
className="template-modal-tab-btn"
|
|
onClick={() => setActiveTab(tab.value)}
|
|
style={{
|
|
...tabStyle,
|
|
...(activeTab === tab.value ? tabActiveStyle : {}),
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (activeTab !== tab.value) (e.currentTarget as HTMLElement).style.background = 'var(--color-bg-hover)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (activeTab !== tab.value) (e.currentTarget as HTMLElement).style.background = 'transparent';
|
|
}}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Template Grid */}
|
|
<div style={gridContainerStyle}>
|
|
<div style={gridStyle}>
|
|
{filtered.map((tpl) => (
|
|
<TemplateCard
|
|
key={tpl.id}
|
|
template={tpl}
|
|
onSelect={() => setConfirmTemplate(tpl)}
|
|
/>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: 40, color: '#71717a' }}>
|
|
No templates in this category.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Confirmation Dialog */}
|
|
{confirmTemplate && (
|
|
<div style={confirmOverlayStyle} onClick={() => setConfirmTemplate(null)}>
|
|
<div style={confirmDialogStyle} onClick={(e) => e.stopPropagation()}>
|
|
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: '#e4e4e7' }}>
|
|
Load "{confirmTemplate.name}"?
|
|
</h3>
|
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: '#a1a1aa' }}>
|
|
This will replace your current content with the template pages and components.
|
|
</p>
|
|
|
|
<label
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
fontSize: 13,
|
|
color: '#e4e4e7',
|
|
cursor: 'pointer',
|
|
marginBottom: 24,
|
|
padding: '10px 12px',
|
|
backgroundColor: 'var(--color-bg-elevated)',
|
|
borderRadius: 6,
|
|
border: '1px solid var(--color-border)',
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={applyDesign}
|
|
onChange={(e) => setApplyDesign(e.target.checked)}
|
|
style={{ width: 16, height: 16, accentColor: '#3b82f6', cursor: 'pointer' }}
|
|
/>
|
|
Apply template colors and fonts to site design
|
|
</label>
|
|
|
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
|
<button
|
|
onClick={() => setConfirmTemplate(null)}
|
|
style={{
|
|
padding: '8px 20px',
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: '#a1a1aa',
|
|
background: 'var(--color-bg-base)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleLoad}
|
|
disabled={loading}
|
|
style={{
|
|
padding: '8px 24px',
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
color: '#ffffff',
|
|
background: '#3b82f6',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
cursor: loading ? 'wait' : 'pointer',
|
|
opacity: loading ? 0.7 : 1,
|
|
}}
|
|
>
|
|
{loading ? 'Loading...' : 'Load Template'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>,
|
|
document.body,
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TemplateCard sub-component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TemplateCard: React.FC<{
|
|
template: TemplateDefinition;
|
|
onSelect: () => void;
|
|
}> = ({ template, onSelect }) => {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
return (
|
|
<div
|
|
{...clickableProps(onSelect)}
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
style={{
|
|
borderRadius: 8,
|
|
border: `1px solid ${hovered ? 'var(--color-accent)' : 'var(--color-border)'}`,
|
|
backgroundColor: hovered ? 'var(--color-bg-elevated)' : 'var(--color-bg-surface)',
|
|
cursor: 'pointer',
|
|
overflow: 'hidden',
|
|
transition: 'all 0.2s ease',
|
|
transform: hovered ? 'translateY(-2px)' : 'none',
|
|
boxShadow: hovered ? '0 4px 12px rgba(0,0,0,0.3)' : 'none',
|
|
}}
|
|
>
|
|
{/* Thumbnail */}
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: 120,
|
|
backgroundColor: '#1a1a24',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<img
|
|
src={template.thumbnail}
|
|
alt={template.name}
|
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div style={{ padding: '12px 14px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#e4e4e7' }}>
|
|
{template.name}
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: 10,
|
|
fontWeight: 600,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.5px',
|
|
color: CATEGORY_COLORS[template.category],
|
|
backgroundColor: `${CATEGORY_COLORS[template.category]}18`,
|
|
padding: '2px 6px',
|
|
borderRadius: 4,
|
|
}}
|
|
>
|
|
{template.category}
|
|
</span>
|
|
</div>
|
|
<p style={{ margin: 0, fontSize: 11, color: '#71717a', lineHeight: 1.4 }}>
|
|
{template.description}
|
|
</p>
|
|
<div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<span
|
|
style={{
|
|
fontSize: 10,
|
|
color: '#52525b',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<i className="fa fa-file-o" style={{ fontSize: 9 }} />
|
|
{template.isMultiPage ? `${template.pages.length} pages` : 'Single page'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const modalStyle: React.CSSProperties = {
|
|
width: '90vw',
|
|
maxWidth: 900,
|
|
maxHeight: '85vh',
|
|
backgroundColor: 'var(--color-bg-surface)',
|
|
borderRadius: 12,
|
|
border: '1px solid var(--color-border)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
|
};
|
|
|
|
const modalHeaderStyle: React.CSSProperties = {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: '16px 20px',
|
|
borderBottom: '1px solid var(--color-border)',
|
|
flexShrink: 0,
|
|
};
|
|
|
|
const closeButtonStyle: React.CSSProperties = {
|
|
width: 32,
|
|
height: 32,
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: 14,
|
|
color: '#71717a',
|
|
background: 'transparent',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
};
|
|
|
|
const tabBarStyle: React.CSSProperties = {
|
|
display: 'flex',
|
|
gap: 4,
|
|
padding: '12px 20px',
|
|
borderBottom: '1px solid var(--color-border)',
|
|
flexShrink: 0,
|
|
overflowX: 'auto',
|
|
};
|
|
|
|
const tabStyle: React.CSSProperties = {
|
|
padding: '6px 16px',
|
|
fontSize: 12,
|
|
fontWeight: 500,
|
|
color: '#71717a',
|
|
background: 'transparent',
|
|
border: '1px solid transparent',
|
|
borderRadius: 6,
|
|
cursor: 'pointer',
|
|
whiteSpace: 'nowrap',
|
|
transition: 'all 0.15s ease',
|
|
};
|
|
|
|
const tabActiveStyle: React.CSSProperties = {
|
|
color: '#e4e4e7',
|
|
background: 'var(--color-bg-elevated)',
|
|
borderColor: 'var(--color-border)',
|
|
};
|
|
|
|
const gridContainerStyle: React.CSSProperties = {
|
|
flex: 1,
|
|
overflowY: 'auto',
|
|
padding: '16px 20px 20px',
|
|
};
|
|
|
|
const gridStyle: React.CSSProperties = {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
|
gap: 16,
|
|
};
|
|
|
|
const confirmOverlayStyle: React.CSSProperties = {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderRadius: 12,
|
|
zIndex: 1,
|
|
};
|
|
|
|
const confirmDialogStyle: React.CSSProperties = {
|
|
width: '90%',
|
|
maxWidth: 420,
|
|
padding: '24px',
|
|
backgroundColor: 'var(--color-bg-surface)',
|
|
borderRadius: 10,
|
|
border: '1px solid var(--color-border)',
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
|
};
|