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