// app-main.jsx — App shell: header, upload, outline rail, view switch, export, persistence
(function () {
const { useState, useRef, useEffect, useCallback } = React;
const { parseAgenda, SAMPLE_TEXT, PRESENTERS } = window.MeetingData;
const { ItemCard, MinutesPreview } = window;
const STORE_KEY = "board_minutes_v1";
const DEFAULT_META = {
org: "Riverside Community Foundation",
kind: "Regular Meeting",
date: "2026-06-08",
time: "6:00 p.m.",
location: "Boardroom & Videoconference",
present: "",
absent: "",
quorum: true,
};
// ---------- persistence ----------
function load() {
try { return JSON.parse(localStorage.getItem(STORE_KEY)) || null; } catch (e) { return null; }
}
function save(state) {
try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch (e) {}
}
// ---------- Word / minutes HTML builder ----------
function buildMinutesHTML(meta, items, title) {
const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
const { fmtDate, voteSentence } = window.MinutesHelpers;
const present = (meta.present || "").split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
const absent = (meta.absent || "").split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
let body = "";
items.forEach((it) => {
const vs = it.vote.recorded ? voteSentence(it.vote) : "";
const lvl = it.level || (it.isSub ? 1 : 0);
const lead = lvl ? " ".repeat(lvl) : "";
const num = it.number ? esc(it.number) + ". " : "";
body += '
' + lead + "" + num + esc(it.title) + "" +
(it.presenter ? ' ' + esc(it.presenter) + "" : "") +
(it.page ? ' p. ' + esc(it.page) + "" : "") + "
";
if (it.notes && it.notes.trim())
it.notes.trim().split(/\n+/).forEach((p) => { body += "" + esc(p) + "
"; });
if (vs) body += "" + esc(vs) + "
";
if (it.actions.length) {
body += 'Action items
';
it.actions.forEach((a) => {
body += "- " + esc(a.text) + (a.owner ? " — " + esc(a.owner) : "") + (a.due ? " (by " + esc(a.due) + ")" : "") + "
";
});
body += "
";
}
body += "";
});
return '' + esc(title || "Minutes") +
'' +
"" + esc(meta.org) + "
" +
'Minutes of the ' + esc(meta.kind) + " of the Board of Directors
" +
'' + esc(fmtDate(meta.date)) + (meta.time ? " · " + esc(meta.time) : "") + (meta.location ? " · " + esc(meta.location) : "") + "
" +
'Directors present' + esc(present.join(", ") || "—") + "
" +
(absent.length ? "
Absent" + esc(absent.join(", ")) + "
" : "") +
"
Quorum" + (meta.quorum ? "A quorum was present." : "Not established.") + "
" +
'" +
'_____________________________
Secretary
' +
"";
}
function downloadWord(meta, items, title) {
const html = buildMinutesHTML(meta, items, title);
const blob = new Blob(["\ufeff", html], { type: "application/msword" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = (meta.org ? meta.org.replace(/[^\w]+/g, "_") : "Board") + "_Minutes.doc";
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ================= Empty state / Upload =================
function UploadView({ onLoad }) {
const [paste, setPaste] = useState("");
const [busy, setBusy] = useState(null);
const [err, setErr] = useState("");
const inputRef = useRef(null);
function ingest(text, name) {
const parsed = parseAgenda(text);
if (!parsed.items.length) {
setErr("We read the file but couldn’t find any agenda items. Make sure the document lists numbered or bulleted items — or paste the agenda text below.");
return;
}
onLoad(parsed, name);
}
async function handleFile(file) {
if (!file) return;
setErr("");
setBusy(file.name);
try {
const { text, method } = await window.extractAgendaText(file);
const parsed = parseAgenda(text);
setBusy(null);
if (!parsed.items.length) {
setErr("Extracted text from this " + method + " file, but found no agenda structure. Try pasting the agenda below, or use the sample to explore.");
return;
}
onLoad(parsed, parsed.title || file.name.replace(/\.[^.]+$/, ""));
} catch (e) {
setBusy(null);
const msg = String(e && e.message);
if (msg === "not-a-docx" || msg === "docx-lib-missing")
setErr("Couldn’t open that Word file. If it’s an older .doc, re-save as .docx or PDF and try again.");
else if (msg === "pdf-lib-missing")
setErr("The PDF reader didn’t load — check your connection and try again.");
else
setErr("Sorry, we couldn’t read that file (" + msg + "). Try a PDF, .docx, or paste the text below.");
}
}
return (
New session
Turn an agenda into a working notes form
Upload your board agenda and we’ll lay out a note field for every item — with one-tap motion language built in.
{err &&
{err}
}
or paste the agenda text
);
}
// ================= Outline rail =================
function Outline({ items, meta, onJump, onMeta }) {
const [openMeta, setOpenMeta] = useState(false);
const done = items.filter((it) => (it.notes && it.notes.trim()) || it.status || it.vote.recorded || it.actions.length).length;
return (
);
}
function Field({ label, value, onChange, type, area, placeholder }) {
return (
);
}
// ================= App =================
function App() {
const saved = load();
const [meta, setMeta] = useState(saved ? saved.meta : DEFAULT_META);
const [items, setItems] = useState(saved ? saved.items : []);
const [title, setTitle] = useState(saved ? saved.title : "");
const [view, setView] = useState("notes");
const [exportOpen, setExportOpen] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
const scrollRef = useRef(null);
const itemRefs = useRef({});
const firstRender = useRef(true);
// tweaks
const [t, setTweak] = window.useTweaks(window.TWEAK_DEFAULTS);
useEffect(() => {
const r = document.documentElement;
r.setAttribute("data-density", t.density);
r.style.setProperty("--accent", t.accent);
r.style.setProperty("--accent-soft", t.accent + "1f");
}, [t.density, t.accent]);
// persist (debounced-ish)
useEffect(() => {
if (firstRender.current) { firstRender.current = false; return; }
save({ meta, items, title });
setSavedFlash(true);
const id = setTimeout(() => setSavedFlash(false), 1200);
return () => clearTimeout(id);
}, [meta, items, title]);
const patchItem = useCallback((id, changes) => {
setItems((prev) => prev.map((it) => (it.id === id ? Object.assign({}, it, changes) : it)));
}, []);
function onLoad(parsed, name) {
setItems(parsed.items);
if (parsed.meta && Object.keys(parsed.meta).length) {
setMeta((m) => {
const next = Object.assign({}, m);
["org", "date", "time", "location", "kind"].forEach((k) => {
if (parsed.meta[k]) next[k] = parsed.meta[k];
});
return next;
});
}
setTitle(parsed.title || name);
}
function jump(id) {
setView("notes");
requestAnimationFrame(() => {
const el = itemRefs.current[id];
const cont = scrollRef.current;
if (el && cont) cont.scrollTo({ top: el.offsetTop - 18, behavior: "smooth" });
});
}
function startOver() {
if (!confirm("Start a new session? This clears the current notes.")) return;
setItems([]); setTitle(""); setView("notes");
try { localStorage.removeItem(STORE_KEY); } catch (e) {}
}
function printPdf() {
setView("minutes");
setExportOpen(false);
requestAnimationFrame(() => setTimeout(() => window.print(), 120));
}
if (!items.length) {
return (
);
}
return (
setMeta((m) => Object.assign({}, m, c))} />
{view === "notes" ? (
{title || "Meeting notes"}
{items.length} agenda items · type m/s/u + space anywhere to drop in a motion.
{items.map((it, i) => (
{ itemRefs.current[id] = el; }} />
))}
End of agenda
) : (
)}
);
}
// ================= Tweaks panel =================
function Tweaks({ t, setTweak }) {
const { TweaksPanel, TweakSection, TweakRadio, TweakColor } = window;
return (
setTweak("density", v)} />
setTweak("accent", v)} />
);
}
window.MeetingApp = App;
})();