340 lines
13 KiB
JavaScript
340 lines
13 KiB
JavaScript
// 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 (
|
||
<div className="post">
|
||
<div className="a-root">
|
||
<div className="a-head">
|
||
<h1>
|
||
{data.headlineBefore}{data.headlineBefore && data.headlineEm ? ' ' : ''}
|
||
{data.headlineEm ? <em>{data.headlineEm}</em> : null}
|
||
</h1>
|
||
{data.subtitle ? <div className="subtitle">{data.subtitle}</div> : null}
|
||
</div>
|
||
|
||
<div className="a-grid">
|
||
{data.members.map((m, i) => (
|
||
<div key={i} className="member">
|
||
{m.photo
|
||
? <img className="portrait" src={m.photo} alt="" />
|
||
: <div className="portrait-empty">Portrait</div>}
|
||
<div className="name">{m.name}</div>
|
||
<div className="title">{m.title}</div>
|
||
<div className="company">{m.company}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="a-foot">
|
||
<div className="mark">
|
||
<img src="assets/fenja-logo-full.png" alt="Fenja AI" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// 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 (
|
||
<>
|
||
<header className="topbar">
|
||
<div className="brand">
|
||
<img src="assets/fenja-logo-full.png" alt="Fenja AI" style={{ height: 56, marginLeft: -14 }} />
|
||
<div className="title">Board post <em>editor</em></div>
|
||
</div>
|
||
<div className="actions">
|
||
<a className="btn btn-ghost" href="index.html">Compare layouts</a>
|
||
<button className="btn btn-ghost" onClick={onReset}>Reset</button>
|
||
<button className="btn btn-primary" onClick={onDownload} disabled={downloading}>
|
||
{downloading ? 'Rendering…' : 'Download PNG'}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="workspace">
|
||
<section className="form-col">
|
||
<h2>Edit the post.</h2>
|
||
<p className="col-sub">Fill in the headline, subtitle, and the eight members. The preview updates as you type.</p>
|
||
|
||
<div className="section">
|
||
<h3>Headline</h3>
|
||
<div className="grid-2">
|
||
<div className="field">
|
||
<label>Opening</label>
|
||
<input
|
||
type="text"
|
||
value={data.headlineBefore}
|
||
onChange={e => updateField('headlineBefore', e.target.value)}
|
||
placeholder="Meet the Fenja AI"
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>Italicized closer</label>
|
||
<input
|
||
type="text"
|
||
value={data.headlineEm}
|
||
onChange={e => updateField('headlineEm', e.target.value)}
|
||
placeholder="Advisory Board"
|
||
/>
|
||
<div className="hint">The terminal phrase, rendered in serif italic bold.</div>
|
||
</div>
|
||
</div>
|
||
<div className="field" style={{ marginTop: 18 }}>
|
||
<label>Subtitle</label>
|
||
<input
|
||
type="text"
|
||
value={data.subtitle}
|
||
onChange={e => updateField('subtitle', e.target.value)}
|
||
placeholder="Bridging Industry & Sovereign AI"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section">
|
||
<h3>Members · 8 portraits</h3>
|
||
{data.members.map((m, i) => (
|
||
<div key={i} className="member-row">
|
||
<div>
|
||
<div className="number">{String(i + 1).padStart(2, '0')}</div>
|
||
<label className={`dropzone ${m.photo ? 'has-image' : ''}`}>
|
||
{m.photo
|
||
? <img src={m.photo} alt="" />
|
||
: <span className="ph">Drop or click<br/>to add photo</span>}
|
||
<input
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={e => onPhoto(i, e.target.files?.[0])}
|
||
/>
|
||
{m.photo && (
|
||
<button
|
||
className="clear"
|
||
type="button"
|
||
onClick={e => { e.preventDefault(); updateMember(i, 'photo', null); }}
|
||
aria-label="Remove photo"
|
||
>×</button>
|
||
)}
|
||
</label>
|
||
</div>
|
||
<div className="member-fields">
|
||
<div className="field full">
|
||
<label>Name</label>
|
||
<input
|
||
type="text"
|
||
value={m.name}
|
||
onChange={e => updateMember(i, 'name', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>Title</label>
|
||
<input
|
||
type="text"
|
||
value={m.title}
|
||
onChange={e => updateMember(i, 'title', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label>Company</label>
|
||
<input
|
||
type="text"
|
||
value={m.company}
|
||
onChange={e => updateMember(i, 'company', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<aside className="preview-col">
|
||
<h3>
|
||
Preview
|
||
<span className="pixel-tag">1200 × 1200 · PNG</span>
|
||
</h3>
|
||
<div className="preview-frame">
|
||
<div className="stage">
|
||
<BoardPost data={data} />
|
||
</div>
|
||
</div>
|
||
<p className="preview-foot">
|
||
The exported PNG renders at 2× pixel density (2400 × 2400) so the type stays crisp after LinkedIn re-encodes.
|
||
</p>
|
||
</aside>
|
||
</main>
|
||
|
||
{/* Hidden full-size capture target — what html-to-image actually reads. */}
|
||
<div className="capture-host" aria-hidden="true">
|
||
<div ref={captureRef}>
|
||
<BoardPost data={data} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|