0291ddce9a
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
235 lines
7.6 KiB
TypeScript
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',
|
|
};
|