// Marco-Polliio — root app, server-backed identity + items + votes + comments.
//
// Wire-up: every visitor picks a name + face (and optionally an admin password)
// on the first screen; backend issues a Marco token stored in localStorage.
// The token is the bearer credential for every subsequent request.
const STORAGE_TOKEN = 'mp_token_v1';
const STORAGE_TAB = 'mp_tab_v1';
const API_BASE = '/api/v1/marco';
const POLL_MS = 30000;
// ── API helper ─────────────────────────────────────────────────────────────
function getToken() { try { return localStorage.getItem(STORAGE_TOKEN) || ''; } catch { return ''; } }
function setToken(t) { try { t ? localStorage.setItem(STORAGE_TOKEN, t) : localStorage.removeItem(STORAGE_TOKEN); } catch {} }
class MarcoApiError extends Error {
constructor(status, body) { super(body?.message || `HTTP ${status}`); this.status = status; this.body = body; }
}
async function apiFetch(path, opts = {}) {
const headers = { 'content-type': 'application/json', ...(opts.headers || {}) };
const tok = getToken();
if (tok) headers.Authorization = `Marco ${tok}`;
const res = await fetch(API_BASE + path, { ...opts, headers });
let body = null;
const text = await res.text();
if (text) { try { body = JSON.parse(text); } catch { body = text; } }
if (!res.ok) throw new MarcoApiError(res.status, body);
return body;
}
const marcoApi = {
createIdentity: (data) => apiFetch('/identity', { method: 'POST', body: JSON.stringify(data) }),
me: () => apiFetch('/me'),
listItems: (kind) => apiFetch(`/items?kind=${kind}`),
createItem: (kind, data) => apiFetch('/items', { method: 'POST', body: JSON.stringify({ kind, ...data }) }),
updateItem: (id, data) => apiFetch(`/items/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteItem: (id) => apiFetch(`/items/${id}`, { method: 'DELETE' }),
vote: (id, kind) => apiFetch(`/items/${id}/vote`, { method: 'PUT', body: JSON.stringify({ kind }) }),
toggleReaction: (id, emoji) => apiFetch(`/items/${id}/reactions`,{ method: 'PUT', body: JSON.stringify({ emoji }) }),
addComment: (id, data) => apiFetch(`/items/${id}/comments`, { method: 'POST', body: JSON.stringify(data) }),
editComment: (id, data) => apiFetch(`/comments/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteComment: (id) => apiFetch(`/comments/${id}`, { method: 'DELETE' }),
};
// ── Shape adapter ──────────────────────────────────────────────────────────
// Convert backend MarcoItemView → the shape existing UI components expect.
function denormalizeItem(item) {
return {
id: item.id,
kind: item.kind, // 'FEATURE' | 'SUGGESTION'
ts: new Date(item.createdAt).getTime(),
author: item.author.name,
authorId: item.author.id,
title: item.title,
description: item.description,
media: item.media,
voteCounts: item.voteCounts, // { up, down, neutral }
myVote: item.myVote ? item.myVote.toLowerCase() : null, // 'up'|'down'|'neutral'|null
reactions: item.reactions || {}, // { '🔥': { count, mine }, ... }
comments: item.comments.map((c) => ({
id: c.id,
userId: c.author.id,
name: c.author.name,
role: (c.author.role || 'USER').toLowerCase(),
avatar: c.author.avatar,
ts: new Date(c.createdAt).getTime(),
text: c.text,
attachments: c.attachments,
audio: !!c.audio,
edited: !!c.editedAt,
editedTs: c.editedAt ? new Date(c.editedAt).getTime() : undefined,
})),
};
}
// Optimistic-vote helper — mutate counts + myVote without touching the
// rest of the item. Server reply overwrites this within ~80ms.
function applyOptimisticVote(item, newKind) {
const counts = { ...item.voteCounts };
const prev = item.myVote;
if (prev && counts[prev] != null) counts[prev] = Math.max(0, counts[prev] - 1);
if (newKind && counts[newKind] != null) counts[newKind] = counts[newKind] + 1;
return { ...item, voteCounts: counts, myVote: newKind };
}
// Optimistic reaction toggle — flip mine + adjust count for that emoji.
function applyOptimisticReaction(item, emoji) {
const reactions = { ...(item.reactions || {}) };
const cell = reactions[emoji] || { count: 0, mine: false };
if (cell.mine) {
const next = Math.max(0, cell.count - 1);
if (next === 0) delete reactions[emoji];
else reactions[emoji] = { count: next, mine: false };
} else {
reactions[emoji] = { count: cell.count + 1, mine: true };
}
return { ...item, reactions };
}
// ── App ────────────────────────────────────────────────────────────────────
function App() {
const [user, setUser] = React.useState(null);
const [bootstrapped, setBoot] = React.useState(false);
const [bootError, setBootError] = React.useState('');
const [tab, setTab] = React.useState(() => {
try { return localStorage.getItem(STORAGE_TAB) || 'poll'; } catch { return 'poll'; }
});
const [features, setFeatures] = React.useState([]);
const [suggestions, setSuggestions] = React.useState([]);
const [loadingItems, setLoadingItems] = React.useState(false);
const [listError, setListError] = React.useState('');
const [showAdd, setShowAdd] = React.useState(false);
const [editTarget, setEditTarget] = React.useState(null);
const [drawCallback, setDrawCallback] = React.useState(null);
const [lightbox, setLightbox] = React.useState(null);
const [annotateTarget, setAnnotateTarget] = React.useState(null);
React.useEffect(() => { try { localStorage.setItem(STORAGE_TAB, tab); } catch {} }, [tab]);
React.useEffect(() => { document.body.setAttribute('data-tab', tab); }, [tab]);
// Boot: validate stored token; fall through to identity picker if none/401.
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
if (!getToken()) { if (!cancelled) setBoot(true); return; }
const r = await marcoApi.me();
if (!cancelled) { setUser(r.user); setBoot(true); }
} catch (err) {
if (cancelled) return;
if (err.status === 401) setToken('');
else setBootError(err.message || "Couldn't reach the backend.");
setBoot(true);
}
})();
return () => { cancelled = true; };
}, []);
const fetchItems = React.useCallback(async (showSpinner = true) => {
if (!user) return;
if (showSpinner) setLoadingItems(true);
setListError('');
try {
const kind = tab === 'poll' ? 'feature' : 'suggestion';
const rows = await marcoApi.listItems(kind);
const itemsList = rows.map(denormalizeItem);
if (tab === 'poll') setFeatures(itemsList); else setSuggestions(itemsList);
} catch (err) {
if (err.status === 401) { setToken(''); setUser(null); return; }
setListError(err.message || 'Failed to load.');
} finally {
if (showSpinner) setLoadingItems(false);
}
}, [user, tab]);
React.useEffect(() => { fetchItems(true); }, [fetchItems]);
// Background polling so other users' changes show up without a refresh.
React.useEffect(() => {
if (!user) return;
const t = setInterval(() => { fetchItems(false); }, POLL_MS);
return () => clearInterval(t);
}, [user, fetchItems]);
const submitIdentity = async ({ name, avatar, password }) => {
try {
const r = await marcoApi.createIdentity({ name, avatar, password: password || undefined });
setToken(r.token);
setUser(r.user);
return null;
} catch (err) {
return err.body?.message || err.message || "Couldn't create identity.";
}
};
const logout = () => { setToken(''); setUser(null); setFeatures([]); setSuggestions([]); };
// active collection + helpers
const items = tab === 'poll' ? features : suggestions;
const setItems = tab === 'poll' ? setFeatures : setSuggestions;
const noun = tab === 'poll' ? 'feature' : 'suggestion';
const isAdmin = user?.role === 'ADMIN';
const canCreate = !!user && (tab === 'poll' ? isAdmin : true);
const canManage = (item) => !!user && (isAdmin || item.authorId === user.id);
const canCommentManage = (c) => !!user && (isAdmin || c.userId === user.id);
// Every backend mutator returns the freshly-viewed parent — single-call replace.
const replaceItem = (kind, view) => {
const denorm = denormalizeItem(view);
const updater = (xs) => {
const idx = xs.findIndex((x) => x.id === denorm.id);
if (idx === -1) return [denorm, ...xs];
const copy = xs.slice();
copy[idx] = denorm;
return copy;
};
if (kind === 'FEATURE') setFeatures(updater); else setSuggestions(updater);
};
// Mutators
const addItem = async ({ title, description, media }) => {
try {
const kind = tab === 'poll' ? 'feature' : 'suggestion';
const view = await marcoApi.createItem(kind, { title, description, media });
replaceItem(view.kind, view);
setShowAdd(false);
} catch (err) { alert(err.body?.message || err.message || "Couldn't create."); }
};
const updateItem = async ({ title, description, media }) => {
const target = editTarget;
try {
const view = await marcoApi.updateItem(target.id, { title, description, media });
replaceItem(view.kind, view);
setEditTarget(null);
} catch (err) { alert(err.body?.message || err.message || "Couldn't update."); }
};
const deleteItem = async (id) => {
if (!confirm(`Delete this ${noun} and all its comments?`)) return;
try {
await marcoApi.deleteItem(id);
setItems((xs) => xs.filter((x) => x.id !== id));
} catch (err) { alert(err.body?.message || err.message || "Couldn't delete."); }
};
const vote = async (id, kind) => {
setItems((xs) => xs.map((x) => x.id !== id ? x : applyOptimisticVote(x, kind)));
try {
const view = await marcoApi.vote(id, kind);
replaceItem(view.kind, view);
} catch (err) { console.warn('Vote failed', err); fetchItems(false); }
};
const toggleReaction = async (id, emoji) => {
setItems((xs) => xs.map((x) => x.id !== id ? x : applyOptimisticReaction(x, emoji)));
try {
const view = await marcoApi.toggleReaction(id, emoji);
replaceItem(view.kind, view);
} catch (err) { console.warn('Reaction failed', err); fetchItems(false); }
};
const addComment = async (id, { text, attachments, audio }) => {
try {
const view = await marcoApi.addComment(id, { text, attachments, audio: audio ? 'voice' : null });
replaceItem(view.kind, view);
} catch (err) { alert(err.body?.message || err.message || "Couldn't post comment."); }
};
const editComment = async (_itemId, commentId, { text }) => {
try {
const view = await marcoApi.editComment(commentId, { text });
replaceItem(view.kind, view);
} catch (err) { alert(err.body?.message || err.message || "Couldn't edit comment."); }
};
const deleteComment = async (_itemId, commentId) => {
if (!confirm('Delete this comment?')) return;
try {
const view = await marcoApi.deleteComment(commentId);
replaceItem(view.kind, view);
} catch (err) { alert(err.body?.message || err.message || "Couldn't delete comment."); }
};
const onAnnotateSave = (dataUrl) => {
if (!annotateTarget) return;
addComment(annotateTarget.featureId, { text: '✎ annotation', attachments: [dataUrl] });
setAnnotateTarget(null);
};
const requestDraw = (cb) => setDrawCallback(() => cb);
const onDrawSave = (src) => { drawCallback?.(src); setDrawCallback(null); };
const openNew = (which) => {
const targetTab = which === 'feature' ? 'poll' : 'suggestions';
if (tab !== targetTab) setTab(targetTab);
setShowAdd(true);
};
if (!bootstrapped) return
;
if (!user) return ;
// UI components expect role lower-case ('admin' / 'user'); normalize once.
const userView = { ...user, role: user.role.toLowerCase() };
return (
<>
openNew('feature')}
onNewSuggestion={() => openNew('suggestion')}
/>
{listError && {listError}
}
{loadingItems && items.length === 0 ? (
loading…
) : items.length === 0 ? (
nothing posted yet
{tab === 'poll'
? (canCreate
?
Hit new feature in the toolbar above to seed the first one.
:
The dev hasn't posted anything yet. Check back soon.
)
:
Be the first to suggest something — hit new suggestion in the toolbar above.
}
) : (
{items.map((x) => (
setEditTarget(t)}
onEditComment={editComment}
onDeleteComment={deleteComment}
onZoom={(src, featureId) => setLightbox({ src, featureId: featureId || x.id })}
onRequestDraw={requestDraw}
/>
))}
)}
{showAdd && (
setShowAdd(false)} onSave={addItem} />
)}
{editTarget && (
setEditTarget(null)} onSave={updateItem} />
)}
{drawCallback && (
setDrawCallback(null)} onSave={onDrawSave} />
)}
{annotateTarget && (
setAnnotateTarget(null)}
onSave={onAnnotateSave}
/>
)}
setLightbox(null)}
onAnnotate={(src, featureId) => { setLightbox(null); setAnnotateTarget({ src, featureId }); }}
/>
>
);
}
ReactDOM.createRoot(document.getElementById('root')).render();