Merge branch 'site-builder-feedback-batch'
This commit is contained in:
@@ -29,6 +29,8 @@ const platformIcons: Record<string, string> = {
|
||||
pinterest: 'fa-pinterest',
|
||||
snapchat: 'fa-snapchat',
|
||||
whatsapp: 'fa-whatsapp',
|
||||
spotify: 'fa-spotify',
|
||||
twitch: 'fa-twitch',
|
||||
};
|
||||
|
||||
const platformLabels: Record<string, string> = {
|
||||
@@ -42,6 +44,8 @@ const platformLabels: Record<string, string> = {
|
||||
pinterest: 'Pinterest',
|
||||
snapchat: 'Snapchat',
|
||||
whatsapp: 'WhatsApp',
|
||||
spotify: 'Spotify',
|
||||
twitch: 'Twitch',
|
||||
};
|
||||
|
||||
const allPlatforms = Object.keys(platformIcons);
|
||||
|
||||
@@ -7,6 +7,30 @@ interface FeatureItem {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
mediaType?: 'icon' | 'image'; // default behaves as 'icon' when undefined
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
buttonText?: string;
|
||||
buttonUrl?: string;
|
||||
}
|
||||
|
||||
/* ---------- Image upload helper (same as Navbar/ImageBlock) ---------- */
|
||||
|
||||
async function uploadToWhp(file: File): Promise<string | null> {
|
||||
const cfg = (window as any).WHP_CONFIG;
|
||||
if (!cfg) return URL.createObjectURL(file);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success && data.url) return data.url;
|
||||
return null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
interface FeaturesGridProps {
|
||||
@@ -56,9 +80,26 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
{feat.mediaType === 'image' && feat.image ? (
|
||||
<img
|
||||
src={feat.image}
|
||||
alt={feat.imageAlt || feat.title || ''}
|
||||
style={{ maxWidth: '100%', height: 'auto', marginBottom: '16px', borderRadius: '8px' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
|
||||
)}
|
||||
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
|
||||
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
|
||||
{feat.buttonText ? (
|
||||
<a
|
||||
href={feat.buttonUrl || '#'}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: '#3b82f6', color: '#fff', borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }}
|
||||
>
|
||||
{feat.buttonText}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -88,6 +129,11 @@ const FeaturesGridSettings: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageUpload = async (index: number, file: File) => {
|
||||
const url = await uploadToWhp(file);
|
||||
if (url) updateFeature(index, 'image', url);
|
||||
};
|
||||
|
||||
const addFeature = () => {
|
||||
setProp((p: FeaturesGridProps) => {
|
||||
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧' }];
|
||||
@@ -147,6 +193,52 @@ const FeaturesGridSettings: React.FC = () => {
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
|
||||
{/* Icon / Image toggle */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['icon', 'image'] as const).map((mt) => {
|
||||
const active = (feat.mediaType || 'icon') === mt;
|
||||
return (
|
||||
<button
|
||||
key={mt}
|
||||
onClick={() => updateFeature(i, 'mediaType', mt)}
|
||||
style={{
|
||||
flex: 1, padding: '3px 6px', fontSize: 11, cursor: 'pointer',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{mt === 'icon' ? 'Icon' : 'Image'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(feat.mediaType || 'icon') === 'image' ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<label
|
||||
style={{ padding: '3px 6px', fontSize: 11, cursor: 'pointer', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, flex: 'none', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Upload
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleImageUpload(i, f); }}
|
||||
/>
|
||||
</label>
|
||||
<input type="text" value={feat.image || ''} onChange={(e) => updateFeature(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
<input type="text" value={feat.imageAlt || ''} onChange={(e) => updateFeature(i, 'imageAlt', e.target.value)} placeholder="Alt text (optional)" style={inputStyle} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Button (optional) */}
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginTop: 2 }}>Button (optional)</label>
|
||||
<input type="text" value={feat.buttonText || ''} onChange={(e) => updateFeature(i, 'buttonText', e.target.value)} placeholder="Button text" style={inputStyle} />
|
||||
<input type="text" value={feat.buttonUrl || ''} onChange={(e) => updateFeature(i, 'buttonUrl', e.target.value)} placeholder="https://... or /page" style={inputStyle} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -190,10 +282,16 @@ FeaturesGrid.craft = {
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
const cards = (props.features || defaultFeatures).map((feat) => {
|
||||
const media = feat.mediaType === 'image' && feat.image
|
||||
? `<img src="${esc(feat.image)}" alt="${esc(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
|
||||
: `<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>`;
|
||||
const button = feat.buttonText
|
||||
? `\n <a href="${esc(feat.buttonUrl || '#')}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${esc(feat.buttonText)}</a>`
|
||||
: '';
|
||||
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
|
||||
<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>
|
||||
${media}
|
||||
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
|
||||
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>
|
||||
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>${button}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
|
||||
@@ -48,9 +48,10 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
|
||||
data-zone-preview={zone}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 40,
|
||||
// Slim hint bar, not a content-height band — an empty zone should not
|
||||
// read as a stray spacer between the page and the header/footer.
|
||||
padding: '5px 12px',
|
||||
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
|
||||
padding: '12px 24px',
|
||||
color: zone === 'header' ? '#9ca3af' : '#64748b',
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
|
||||
@@ -60,6 +60,60 @@ const EMPTY_HEADER =
|
||||
const EMPTY_FOOTER =
|
||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#0f172a","color":"#94a3b8","padding":"40px 24px","textAlign":"center"},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||
|
||||
// Default header seed: a ROOT header Container holding a Navbar with default
|
||||
// links. New sites previously opened with an EMPTY header (just a bare
|
||||
// Container), so there was no menu to edit and the empty zone rendered as a
|
||||
// stray band above the page. Seeding a real Navbar gives every new site an
|
||||
// editable menu-with-links out of the box (and removes the empty-header gap).
|
||||
// Node shape matches serializeTreeForCraft() / Craft's actions.deserialize().
|
||||
export const DEFAULT_HEADER_STATE = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { style: { width: '100%' }, tag: 'header' },
|
||||
displayName: 'Container',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: ['header-navbar'],
|
||||
linkedNodes: {},
|
||||
},
|
||||
'header-navbar': {
|
||||
type: { resolvedName: 'Navbar' },
|
||||
isCanvas: false,
|
||||
props: {
|
||||
logoType: 'text',
|
||||
logoText: 'MySite',
|
||||
logoImage: '',
|
||||
logoWidth: '120px',
|
||||
logoUrl: '/',
|
||||
logoFontFamily: 'Inter, sans-serif',
|
||||
logoFontSize: '20px',
|
||||
links: [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About', href: '#about' },
|
||||
{ text: 'Services', href: '#services' },
|
||||
{ text: 'Contact', href: '#contact', isCta: true },
|
||||
],
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#3f3f46',
|
||||
hoverColor: '#3b82f6',
|
||||
ctaColor: '#3b82f6',
|
||||
ctaTextColor: '#ffffff',
|
||||
padding: '16px 24px',
|
||||
navAlignment: 'space-between',
|
||||
isSticky: false,
|
||||
showMobileMenu: false,
|
||||
style: { borderBottom: '1px solid #e4e4e7' },
|
||||
},
|
||||
displayName: 'Navbar',
|
||||
parent: 'ROOT',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
|
||||
const PageContext = createContext<PageContextValue>({
|
||||
pages: [],
|
||||
headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '' },
|
||||
@@ -106,7 +160,7 @@ const DEFAULT_HEADER: PageData = {
|
||||
id: HEADER_ID,
|
||||
name: 'Header',
|
||||
slug: '__header__',
|
||||
craftState: null,
|
||||
craftState: DEFAULT_HEADER_STATE,
|
||||
headCode: '',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { DEFAULT_HEADER_STATE } from './PageContext';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
|
||||
/**
|
||||
* The default header is seeded as a hand-authored serialized Craft state. A
|
||||
* malformed node map would not fail typecheck but would break the header on
|
||||
* every new site at runtime, so we validate it deserializes + exports through
|
||||
* the same path the editor preview and the WHP publish step use.
|
||||
*/
|
||||
describe('DEFAULT_HEADER_STATE seed', () => {
|
||||
test('is valid JSON with a ROOT header + a Navbar child', () => {
|
||||
const parsed = JSON.parse(DEFAULT_HEADER_STATE);
|
||||
expect(parsed.ROOT).toBeDefined();
|
||||
expect(parsed.ROOT.type.resolvedName).toBe('Container');
|
||||
expect(parsed.ROOT.props.tag).toBe('header');
|
||||
expect(parsed.ROOT.nodes).toContain('header-navbar');
|
||||
expect(parsed['header-navbar'].type.resolvedName).toBe('Navbar');
|
||||
expect(parsed['header-navbar'].parent).toBe('ROOT');
|
||||
});
|
||||
|
||||
test('exports to header HTML containing the nav and its default links', () => {
|
||||
const { html } = exportBodyHtml(DEFAULT_HEADER_STATE);
|
||||
expect(html).toContain('<nav');
|
||||
expect(html).toContain('MySite');
|
||||
for (const link of ['Home', 'About', 'Services', 'Contact']) {
|
||||
expect(html).toContain(link);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
# Site Builder — feedback batch (2026-07-06)
|
||||
|
||||
Six items from user feedback on the Craft.js site builder (`/workspace/site-builder/craft/`).
|
||||
Scope decisions were made with the user before writing this spec. Each item is
|
||||
independently shippable.
|
||||
|
||||
## 1 & 2 — Spotify + Twitch social links
|
||||
**File:** `craft/src/components/basic/SocialLinks.tsx`
|
||||
Add `spotify → { icon: 'fa-spotify', label: 'Spotify' }` and `twitch → { icon:
|
||||
'fa-twitch', label: 'Twitch' }` to `platformIcons` + `platformLabels`. The "Add
|
||||
Platform" dropdown, editor render, and `toHtml` pick them up automatically.
|
||||
Font Awesome 4.7.0 (loaded in `craft/index.html`) already ships both glyphs — no
|
||||
CDN change. **Twitch live-status indicator is explicitly deferred** (needs a
|
||||
Twitch app Client-ID + published-site JS) — see Follow-ups.
|
||||
|
||||
## 3 — Features: image option + optional button (structured, no canvas rewrite)
|
||||
**File:** `craft/src/components/sections/FeaturesGrid.tsx`
|
||||
Extend `FeatureItem`:
|
||||
```ts
|
||||
mediaType?: 'icon' | 'image'; // default 'icon'
|
||||
image?: string; imageAlt?: string;
|
||||
buttonText?: string; buttonUrl?: string;
|
||||
```
|
||||
- Per-feature settings gain an icon/image toggle. Image mode = upload + URL input,
|
||||
reusing the `uploadToWhp(file)` helper pattern from `Navbar.tsx`/`ImageBlock.tsx`.
|
||||
- Optional button fields (text + url); render a button under the description when
|
||||
`buttonText` is set.
|
||||
- Editor render + `toHtml` updated: `<img>` when `mediaType==='image'`, else the
|
||||
glyph; button block when present. Backward compatible (missing `mediaType`
|
||||
falls back to icon).
|
||||
|
||||
## 4 & 6 — Menu links in the header (seed a real default header)
|
||||
**Files:** `craft/src/state/PageContext.tsx` (default header craftState), minor
|
||||
`craft/src/panels/left/PagesPanel.tsx` hint.
|
||||
Root cause of "no obvious way to add links to the menu": the default header
|
||||
(`DEFAULT_HEADER` craftState) is an **empty `Container`** — new sites open with a
|
||||
blank header and nothing to edit. Fix: seed the default header with a `Navbar`
|
||||
(logo + page links + CTA) so every new site has an editable menu out of the box.
|
||||
Add a one-line hint in the header-edit empty state pointing at the Navbar's Links
|
||||
section. No new component.
|
||||
|
||||
## 5 — "Spacer" gap between page and header
|
||||
**File:** `craft/src/components/layout/HeaderZone.tsx` (+ header default from #4/6)
|
||||
Same root cause: the empty header zone's `minHeight` renders as a blank band that
|
||||
reads as a stray spacer. Seeding real header content (item 4/6) removes it in the
|
||||
common case. Additionally collapse the empty-state min-height so a deliberately
|
||||
empty header leaves no gap. **Reproduce first** to confirm there is not also a
|
||||
literal stray `Spacer` node before finalizing.
|
||||
|
||||
## 7 — Contact Form email delivery
|
||||
**Files:** `craft/src/components/forms/ContactForm.tsx`,
|
||||
`whp/web-files/api/site-builder.php` (publish/deploy path), self-contained
|
||||
`contact-handler.php` dropped into the site docroot at publish.
|
||||
- Add a **"Send submissions to" recipient email** field.
|
||||
- Published sites live on the customer docroot (not the panel), so the working
|
||||
form needs a handler at the site. At publish/deploy time, if any page contains
|
||||
a ContactForm, write a self-contained `contact-handler.php` into the docroot
|
||||
with the recipient stored server-side (sidecar config, kept out of client HTML
|
||||
to avoid harvesting).
|
||||
- `toHtml` emits `action="contact-handler.php"`, a hidden honeypot field, and a
|
||||
thank-you redirect. Handler validates + sends via PHP `mail()`.
|
||||
- SMTP hardening via the existing mailer noted as a Follow-up.
|
||||
|
||||
## Build & deploy
|
||||
Frontend: `cd craft && npm run build` (runs `tsc && vite build`). Deploy is the
|
||||
standard WHP release pipeline (copy `dist/` into the whp repo → build-release →
|
||||
download-update); **not** auto-deployed here — customer-facing UI, deploy is a
|
||||
separate authorized step.
|
||||
|
||||
## Follow-ups (not in this batch)
|
||||
- Twitch live-status badge (Twitch Helix + published-site JS + Client-ID config).
|
||||
- ContactForm SMTP delivery via the WHP mailer instead of PHP `mail()`.
|
||||
|
||||
## Build order
|
||||
1&2 (trivial) → 3 → 4/5/6 (shared header work) → 7 (largest, touches deploy path).
|
||||
Reference in New Issue
Block a user