// editor.jsx — Board post editor. // // Two-column workspace: a form on the left to edit headline/subtitle and // the 8 member entries (text + photo upload), and a sticky preview on the // right that scales the live 1200×1200 post to 540×540 for display. A // hidden, unscaled clone of the post sits off-screen and is what gets // passed to html-to-image at download time, so the exported PNG is a // pixel-clean 2400×2400 (pixelRatio 2 over the 1200 source). // // Everything persists to localStorage on every change — refresh-safe. // Uploaded photos are downscaled to ~800px and re-encoded as JPEG before // being stored, so eight portraits still fit comfortably under the 5MB // localStorage cap. const { useState, useEffect, useRef, useCallback } = React; const STORAGE_KEY = 'fenja-board-data-v1'; const MIGRATION_KEY = 'fenja-board-migrations'; // Apply one-time data migrations. Each entry runs once per browser. function applyMigrations(parsed) { let applied = []; try { applied = JSON.parse(localStorage.getItem(MIGRATION_KEY) || '[]'); } catch {} // 2026-05-swap-34-67: user asked to swap positions 3↔6 and 4↔7. if (!applied.includes('2026-05-swap-34-67') && parsed?.members?.length === 8) { const m = parsed.members.slice(); [m[2], m[5]] = [m[5], m[2]]; // index 2 ↔ 5 (positions 3 ↔ 6) [m[3], m[6]] = [m[6], m[3]]; // index 3 ↔ 6 (positions 4 ↔ 7) parsed = { ...parsed, members: m }; applied.push('2026-05-swap-34-67'); } try { localStorage.setItem(MIGRATION_KEY, JSON.stringify(applied)); } catch {} return parsed; } const DEFAULT_DATA = { headlineBefore: 'Meet the Fenja AI', headlineEm: 'Advisory Board', subtitle: 'Bridging Industry & Sovereign AI', members: [ { name: '[ Full Name ]', title: 'Former CTO', company: '[ Company ]', photo: null }, { name: '[ Full Name ]', title: 'Professor', company: '[ Institution ]', photo: null }, { name: '[ Full Name ]', title: 'CEO', company: '[ Company ]', photo: null }, { name: '[ Full Name ]', title: 'Head of Research', company: '[ Institute ]', photo: null }, { name: '[ Full Name ]', title: 'Partner', company: '[ Firm ]', photo: null }, { name: '[ Full Name ]', title: 'CEO', company: '[ Company ]', photo: null }, { name: '[ Full Name ]', title: 'Chief Scientist', company: '[ Company ]', photo: null }, { name: '[ Full Name ]', title: 'Founder', company: '[ Company ]', photo: null }, ], }; // ──────────────────────────────────────────────────────────────────────── // Image compression — JPEG at max 800px to keep localStorage happy // ──────────────────────────────────────────────────────────────────────── async function compressImage(file, maxDim = 800, quality = 0.88) { const url = URL.createObjectURL(file); try { const img = new Image(); await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url; }); const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); const w = Math.round(img.width * scale); const h = Math.round(img.height * scale); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, w, h); return canvas.toDataURL('image/jpeg', quality); } finally { URL.revokeObjectURL(url); } } // ──────────────────────────────────────────────────────────────────────── // The post itself — mirrors Variation A in index.html. Renders into a // 1200×1200 box; CSS lives in editor.html so it applies to both the // visible scaled preview and the hidden capture host. // ──────────────────────────────────────────────────────────────────────── function BoardPost({ data }) { return (

{data.headlineBefore}{data.headlineBefore && data.headlineEm ? ' ' : ''} {data.headlineEm ? {data.headlineEm} : null}

{data.subtitle ?
{data.subtitle}
: null}
{data.members.map((m, i) => (
{m.photo ? :
Portrait
}
{m.name}
{m.title}
{m.company}
))}
Fenja AI
); } // ──────────────────────────────────────────────────────────────────────── // Editor // ──────────────────────────────────────────────────────────────────────── function EditorApp() { const [data, setData] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { let parsed = JSON.parse(saved); parsed = applyMigrations(parsed); // Merge with defaults to handle schema additions return { ...DEFAULT_DATA, ...parsed, members: parsed.members && parsed.members.length === 8 ? parsed.members : DEFAULT_DATA.members, }; } } catch {} return DEFAULT_DATA; }); const [downloading, setDownloading] = useState(false); const captureRef = useRef(null); // Persist on every change useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (err) { console.warn('localStorage save failed', err); } }, [data]); const updateField = (key, value) => setData(d => ({ ...d, [key]: value })); const updateMember = (i, key, value) => setData(d => ({ ...d, members: d.members.map((m, idx) => idx === i ? { ...m, [key]: value } : m), })); const onPhoto = async (i, file) => { if (!file) return; try { const dataUrl = await compressImage(file); updateMember(i, 'photo', dataUrl); } catch (err) { console.error('Image processing failed', err); alert('Could not read that image. Try another file?'); } }; const onDownload = async () => { if (!captureRef.current) return; setDownloading(true); try { // Make sure fonts are loaded before capture, otherwise the headline // falls back to Times in the rendered PNG. if (document.fonts && document.fonts.ready) { await document.fonts.ready; } const dataUrl = await window.htmlToImage.toPng(captureRef.current, { pixelRatio: 2, width: 1200, height: 1200, cacheBust: true, backgroundColor: '#faf6ee', }); const link = document.createElement('a'); link.download = `fenja-advisory-board-${new Date().toISOString().slice(0,10)}.png`; link.href = dataUrl; document.body.appendChild(link); link.click(); document.body.removeChild(link); } catch (err) { console.error('Export failed', err); alert('Export failed. Check the console for details.'); } finally { setDownloading(false); } }; const onReset = () => { if (confirm('Reset to defaults? Your edits and uploaded photos will be cleared.')) { setData(DEFAULT_DATA); } }; return ( <>
Fenja AI
Board post editor
Compare layouts

Edit the post.

Fill in the headline, subtitle, and the eight members. The preview updates as you type.

Headline

updateField('headlineBefore', e.target.value)} placeholder="Meet the Fenja AI" />
updateField('headlineEm', e.target.value)} placeholder="Advisory Board" />
The terminal phrase, rendered in serif italic bold.
updateField('subtitle', e.target.value)} placeholder="Bridging Industry & Sovereign AI" />

Members · 8 portraits

{data.members.map((m, i) => (
{String(i + 1).padStart(2, '0')}
updateMember(i, 'name', e.target.value)} />
updateMember(i, 'title', e.target.value)} />
updateMember(i, 'company', e.target.value)} />
))}
{/* Hidden full-size capture target — what html-to-image actually reads. */} ); }