Files
site-builder/craft/src/panels/left/PageSettingsModal.tsx
T

235 lines
7.6 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Modal } from '../../ui/Modal';
import { AssetPicker } from '../../ui/AssetPicker';
import { PageData, PageSeo } from '../../types';
interface PageSettingsModalProps {
open: boolean;
onClose: () => void;
page: PageData | null;
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
}
const EMPTY_SEO: PageSeo = {
metaTitle: '',
metaDescription: '',
ogTitle: '',
ogImage: '',
twitterCard: 'summary_large_image',
noindex: false,
};
/**
* Per-page SEO/meta settings (PKG-H contract §1/§4). Opened via the gear
* icon on a page row in PagesPanel. Fields map 1:1 onto `PageData.seo`
* (`types/index.ts`) and are consumed by `html-export.ts`'s `wrapInDocument`
* (Preview) and the backend's `generateCompiledHTML` (published output) --
* field names here MUST match the shared contract exactly.
*
* Follows the HeadCodeModal precedent: portaled to `document.body` (escapes
* the topbar/left-panel's own stacking context, same fix HeadCodeModal and
* TemplateModal already needed), built on the shared `Modal` shell, edits
* commit immediately via `updatePageSeo` (no separate Save step) rather than
* buffering to a "Save" button -- consistent with HeadCodeModal's live-edit
* pattern for `design.headCode`.
*/
export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onClose, page, updatePageSeo }) => {
const [seo, setSeo] = useState<PageSeo>(EMPTY_SEO);
// Re-sync local form state from the target page's stored seo whenever the
// modal opens (or the target page changes while open) -- mirrors the
// pattern of resetting form-local state on prop identity change rather
// than deriving directly from props (dropdowns/checkboxes need editable
// local state).
useEffect(() => {
if (!open) return;
setSeo({ ...EMPTY_SEO, ...(page?.seo || {}) });
}, [open, page?.id, page?.seo]);
if (!page) return null;
const update = (patch: Partial<PageSeo>) => {
setSeo((prev) => ({ ...prev, ...patch }));
updatePageSeo(page.id, patch);
};
return createPortal(
<Modal open={open} onClose={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div style={modalHeaderStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<i className="fa fa-cog" style={{ color: 'var(--color-accent)', fontSize: 16 }} />
<div>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
Page Settings {page.name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
SEO and social-sharing metadata for this page only.
</div>
</div>
</div>
<button onClick={onClose} style={closeButtonStyle} aria-label="Close">
<i className="fa fa-times" />
</button>
</div>
{/* Body */}
<div style={{ padding: 20, flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={fieldLabelStyle}>Meta Title</label>
<input
type="text"
className="control-input"
value={seo.metaTitle || ''}
onChange={(e) => update({ metaTitle: e.target.value })}
placeholder={page.name}
data-testid="page-seo-meta-title"
/>
<p style={hintStyle}>Overrides the browser tab title. Leave blank to use the page name.</p>
</div>
<div>
<label style={fieldLabelStyle}>Meta Description</label>
<textarea
className="control-input"
value={seo.metaDescription || ''}
onChange={(e) => update({ metaDescription: e.target.value })}
placeholder="A short summary shown in search results and link previews..."
rows={3}
style={{ resize: 'vertical' as const }}
data-testid="page-seo-meta-description"
/>
</div>
<div>
<label style={fieldLabelStyle}>Social Share Title (og:title)</label>
<input
type="text"
className="control-input"
value={seo.ogTitle || ''}
onChange={(e) => update({ ogTitle: e.target.value })}
placeholder={seo.metaTitle || page.name}
data-testid="page-seo-og-title"
/>
</div>
<div>
<label style={fieldLabelStyle}>Social Share Image (og:image)</label>
<AssetPicker
value={seo.ogImage || ''}
onChange={(url) => update({ ogImage: url })}
mediaType="image"
variant="full"
placeholder="Or paste image URL..."
/>
</div>
<div>
<label style={fieldLabelStyle}>Twitter Card Type</label>
<select
className="control-select"
value={seo.twitterCard || 'summary_large_image'}
onChange={(e) => update({ twitterCard: e.target.value as PageSeo['twitterCard'] })}
data-testid="page-seo-twitter-card"
>
<option value="summary">Summary</option>
<option value="summary_large_image">Summary with Large Image</option>
</select>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!seo.noindex}
onChange={(e) => update({ noindex: e.target.checked })}
data-testid="page-seo-noindex"
/>
Hide this page from search engines (noindex)
</label>
</div>
{/* Footer */}
<div style={{
padding: '12px 20px',
borderTop: '1px solid var(--color-border)',
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
}}>
<button onClick={onClose} style={doneButtonStyle}>
Done
</button>
</div>
</div>
</Modal>,
document.body,
);
};
/* ---------- Styles ---------- */
const modalStyle: React.CSSProperties = {
width: '90vw',
maxWidth: 560,
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',
background: 'none',
border: '1px solid var(--color-border)',
borderRadius: 6,
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: 14,
};
const doneButtonStyle: React.CSSProperties = {
padding: '8px 24px',
fontSize: 13,
fontWeight: 600,
background: 'var(--color-accent)',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
};
const fieldLabelStyle: React.CSSProperties = {
display: 'block',
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
marginBottom: 6,
textTransform: 'uppercase',
letterSpacing: '0.3px',
};
const hintStyle: React.CSSProperties = {
fontSize: 10,
color: 'var(--color-text-dim)',
margin: '4px 0 0',
};