Edit the post.
Fill in the headline, subtitle, and the eight members. The preview updates as you type.
// 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 (
Fill in the headline, subtitle, and the eight members. The preview updates as you type.