customer-presentation/editor.jsx
2026-06-11 14:19:02 +02:00

340 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
</>
);
}