site-builder: feedback batch (social, features, header menu, spacer)

Five of six items from user feedback (Contact Form email delivery split
into a focused, live-tested follow-up):

- Social Links: add Spotify + Twitch (FA 4.7.0 already ships both glyphs).
- Features Grid: per-feature icon/image toggle (upload + URL) and an
  optional button (text + url); render, settings, and HTML export updated,
  backward compatible with existing icon-only features.
- Header: seed the default header with a Navbar (logo + Home/About/Services/
  Contact) so new sites open with an editable menu-with-links instead of an
  empty header zone. Adds a vitest guard that the seed deserializes and
  exports a real <nav>.
- Canvas: slim the empty header/footer placeholder from a padded band to a
  thin hint line so an empty zone no longer reads as a stray spacer.

Design spec: docs/superpowers/specs/2026-07-06-site-builder-feedback-batch-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 17:53:22 -07:00
parent d0925d9e2d
commit 53c40f856f
6 changed files with 268 additions and 6 deletions
+101 -3
View File
@@ -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',
}}
>
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
{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 ');