// Marco-Polliio — UI components
// Wordmark, TopNav, Icons, FeaturePanel, CommentsPanel, modals.
const { useState, useEffect, useRef, useMemo, useCallback } = React;
// ── 5 face avatars (real image files served from assets/faces/) ───────
const FACES = [
{ id: 'bj1', src: 'assets/faces/bj1.png' },
{ id: 'bj2', src: 'assets/faces/bj2.png' },
{ id: 'worm', src: 'assets/faces/worm.png' },
{ id: 'ye_stretch', src: 'assets/faces/ye_stretch.png' },
{ id: 'ye_king', src: 'assets/faces/ye_king.png' },
];
// Renders any face id as a circular thumbnail — object-fit cover so wildly
// different aspect-ratio source images all read consistently.
function FaceIcon({ face, size = 32 }) {
const f = (typeof face === 'string') ? FACES.find(x => x.id === face) : face;
if (!f) return null;
return (
);
}
function FacePicker({ value, onChange }) {
return (
{FACES.map((f) => (
onChange(f.id)}
aria-label={f.id} title={f.id}
>
))}
);
}
// ── tiny inline SVG icons (lucide-flavored, 1.5px stroke) ──────────────
function Icon({ name, size = 16 }) {
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round' };
const paths = {
plus: <> >,
trash: <> >,
image: <> >,
mic: <> >,
pencil: <> >,
send: <> >,
close: <> >,
thumbUp: <> >,
thumbDn: <> >,
minus: <> >,
logout: <> >,
eraser: <> >,
edit: <> >,
};
return {paths[name]} ;
}
// ── Animated marco↔polio dance ────────────────────────────────────────
// Two words bounce across the same horizontal margins:
// marco starts left, goes L→R, holds, then R→L home.
// polio starts right, goes R→L (criss-crosses marco), holds, then L→R home.
// Vertical bounces are phase-offset so when one is on the ground the other is in flight.
function PollHero() {
const trackRef = React.useRef(null);
const marcoRef = React.useRef(null);
const polioRef = React.useRef(null);
React.useEffect(() => {
const update = () => {
if (!trackRef.current) return;
const tw = trackRef.current.clientWidth;
const margin = 24;
if (marcoRef.current) {
const mw = marcoRef.current.getBoundingClientRect().width;
marcoRef.current.style.setProperty('--bd', Math.max(0, tw - mw - margin) + 'px');
}
if (polioRef.current) {
const pw = polioRef.current.getBoundingClientRect().width;
polioRef.current.style.setProperty('--bd', Math.max(0, tw - pw - margin) + 'px');
}
};
update();
const ro = new ResizeObserver(update);
ro.observe(trackRef.current);
window.addEventListener('resize', update);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(update);
return () => { ro.disconnect(); window.removeEventListener('resize', update); };
}, []);
return (
marco
pollio
);
}
function Wordmark() {
return (
);
}
// ── TopNav ────────────────────────────────────────────────────────────
function TopNav({ user, onLogout }) {
return (
);
}
// ── Identity screen — one combined first-screen picker ───────────────
// Name + face + optional admin password. Backend issues a token; we never
// store passwords client-side. Fresh browser session = repick.
function IdentityScreen({ onSubmit, initialError }) {
const [name, setName] = useState('');
const [avatar, setAvatar] = useState(FACES[0].id);
const [password, setPassword] = useState('');
const [showPw, setShowPw] = useState(false);
const [err, setErr] = useState(initialError || '');
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
if (!name.trim()) { setErr('Pick a name.'); return; }
setBusy(true);
setErr('');
const msg = await onSubmit({ name: name.trim(), avatar, password: password.trim() });
setBusy(false);
if (msg) setErr(msg);
};
return (
);
}
// ── helpers ────────────────────────────────────────────────────────────
function relTime(ts) {
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return Math.floor(s/60) + 'm ago';
if (s < 86400) return Math.floor(s/3600) + 'h ago';
if (s < 604800) return Math.floor(s/86400) + 'd ago';
return new Date(ts).toLocaleDateString();
}
function fileToDataURL(file) {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = rej;
r.readAsDataURL(file);
});
}
// Compress an image data URL to keep localStorage usage reasonable.
async function compressImage(dataUrl, maxDim = 1280, quality = 0.82) {
return new Promise((res) => {
const img = new Image();
img.onload = () => {
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 c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(img, 0, 0, w, h);
res(c.toDataURL('image/jpeg', quality));
};
img.onerror = () => res(dataUrl);
img.src = dataUrl;
});
}
// ── Normalize feature.media (accept old feature.images for back-compat) ─
// Backend stores media as a flat String[] of data URLs. In-memory the
// modal also accepts {type, src} objects so we have one shape for editing.
// These helpers unify reads across both shapes.
function getMedia(feature) {
if (feature.media && feature.media.length) return feature.media;
if (feature.images && feature.images.length) {
return feature.images.map((src) => ({ type: 'image', src }));
}
return [];
}
function mediaSrc(m) { return (m && typeof m === 'object') ? m.src : m; }
function mediaIsVideo(m) {
if (m && typeof m === 'object' && m.type) return m.type === 'video';
const s = mediaSrc(m) || '';
return s.startsWith('data:video/');
}
// ── Media panel (left — images / videos stacked) ─────────────────────
function MediaPanel({ feature, canManage, onDelete, onZoom }) {
const media = getMedia(feature);
return (
{canManage && (
onDelete(feature.id)} aria-label="Delete">
)}
{media.length === 0 ? (
no media yet — {canManage ? 'edit this card to add images or video.' : 'the author hasn’t attached anything.'}
) : (
{media.map((m, i) => {
const src = mediaSrc(m);
return (
{mediaIsVideo(m)
?
:
onZoom(src)} />}
);
})}
)}
);
}
// ── Description card (right — top) ────────────────────────────────────
function DescCard({ feature, canManage, onEdit }) {
return (
{canManage && (
onEdit(feature)} aria-label="Edit">
)}
feature · {feature.id.slice(-4)}
·
{relTime(feature.ts)}
{feature.title}
{feature.description &&
{feature.description}
}
);
}
// ── Vote bar ──────────────────────────────────────────────────────────
function VoteBar({ feature, onVote }) {
const { up = 0, down = 0, neutral = 0 } = feature.voteCounts || {};
const mine = feature.myVote; // 'up' | 'down' | 'neutral' | null
const pick = (kind) => onVote(feature.id, mine === kind ? null : kind);
return (
pick('up')} title="Thumbs up">
{up}
pick('neutral')} title="Neutral">
{neutral}
pick('down')} title="Thumbs down">
{down}
);
}
// ── Comment item (with expand for long text) ──────────────────────────
const COMMENT_TRUNCATE = 260;
function CommentItem({ comment, canManage, onEdit, onDelete, onZoom }) {
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(comment.text || '');
const long = !editing && comment.text && comment.text.length > COMMENT_TRUNCATE;
const shown = !long || open ? comment.text : comment.text.slice(0, COMMENT_TRUNCATE).trimEnd() + '…';
const startEdit = () => { setEditText(comment.text || ''); setEditing(true); };
const cancelEdit = () => { setEditing(false); setEditText(comment.text || ''); };
const saveEdit = () => {
if (editText.trim() === (comment.text || '').trim()) { setEditing(false); return; }
onEdit(comment.id, { text: editText.trim() });
setEditing(false);
};
return (
{comment.avatar
?
:
{comment.name[0].toUpperCase()}
}
{comment.name}
{comment.role}
{relTime(comment.ts)}
{comment.edited && · edited }
{canManage && !editing && (
onDelete(comment.id)} aria-label="Delete comment" title="Delete">
)}
{editing ? (
) : comment.text && (
{shown}
{long && (
setOpen(!open)}>
{open ? 'show less' : 'show more'}
)}
)}
{comment.audio && (
voice · transcribed
)}
{comment.attachments?.length > 0 && (
{comment.attachments.map((src, i) => (
onZoom(src)} />
))}
)}
);
}
// ── Composer (textarea + mic + image + draw + send) ───────────────────
function Composer({ user, onSend, onRequestDraw }) {
const [text, setText] = useState('');
const [pending, setPending] = useState([]); // [{src, kind: 'img'|'draw'}]
const [audio, setAudio] = useState(false); // marks this comment as voice-derived
const [listening, setListening] = useState(false);
const recogRef = useRef(null);
const fileRef = useRef(null);
const baseTextRef = useRef('');
const addDrawing = useCallback((src) => {
setPending((p) => [...p, { src, kind: 'draw' }]);
}, []);
const startListening = () => {
const Rec = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!Rec) { alert('Speech recognition not supported in this browser. Try Chrome.'); return; }
if (listening) { recogRef.current?.stop(); return; }
const r = new Rec();
r.continuous = true;
r.interimResults = true;
r.lang = navigator.language || 'en-US';
baseTextRef.current = text ? text + (text.endsWith(' ') ? '' : ' ') : '';
r.onresult = (ev) => {
let interim = '', finalTxt = '';
for (let i = ev.resultIndex; i < ev.results.length; i++) {
const t = ev.results[i][0].transcript;
if (ev.results[i].isFinal) finalTxt += t; else interim += t;
}
setText(baseTextRef.current + finalTxt + interim);
if (finalTxt) baseTextRef.current += finalTxt;
};
r.onend = () => { setListening(false); setAudio(true); };
r.onerror = () => { setListening(false); };
recogRef.current = r;
r.start();
setListening(true);
};
const onPickImage = async (e) => {
const files = [...e.target.files];
e.target.value = '';
for (const f of files) {
const raw = await fileToDataURL(f);
const small = await compressImage(raw);
setPending((p) => [...p, { src: small, kind: 'img' }]);
}
};
const send = () => {
if (!text.trim() && pending.length === 0) return;
recogRef.current?.stop();
onSend({
text: text.trim(),
attachments: pending.map((p) => p.src),
audio,
});
setText('');
setPending([]);
setAudio(false);
baseTextRef.current = '';
};
return (
);
}
// ── Reaction bar (stackable emoji) ────────────────────────────────────
const REACTION_PICKS = ['🔥', '💡', '❤️', '👀', '🚀', '❓', '🤔', '✋'];
function ReactionBar({ feature, onReact }) {
const [pickerOpen, setPickerOpen] = useState(false);
const reactions = feature.reactions || {};
// Render existing reactions in REACTION_PICKS order so chips don't reshuffle.
const active = REACTION_PICKS.filter((e) => reactions[e]?.count > 0);
const unused = REACTION_PICKS.filter((e) => !reactions[e]?.count);
return (
{active.map((emoji) => {
const cell = reactions[emoji];
return (
onReact(feature.id, emoji)}
title={cell.mine ? 'click to remove your reaction' : 'click to add this reaction'}
>
{emoji}
{cell.count}
);
})}
setPickerOpen((o) => !o)} aria-label="Add reaction">
+ 😀
{pickerOpen && (
setPickerOpen(false)}>
{unused.length === 0 ? (
all in use — click an active one to remove yours
) : unused.map((emoji) => (
{ onReact(feature.id, emoji); setPickerOpen(false); }}>
{emoji}
))}
)}
);
}
// ── Comment thread (full-width, below the feature) ───────────────────
// Vote/reaction bars live in the feature top section; this is purely
// the conversation thread + composer.
function CommentThread({ feature, user, onComment, onZoom, onRequestDraw, canCommentManage, onEditComment, onDeleteComment }) {
return (
{(feature.comments || []).map((c) => (
onEditComment(feature.id, cid, data)}
onDelete={(cid) => onDeleteComment(feature.id, cid)}
onZoom={(src) => onZoom(src, feature.id)}
/>
))}
{(!feature.comments || feature.comments.length === 0) && (
no comments yet — start the conversation.
)}
onComment(feature.id, c)}
/>
);
}
// ── Feature row ───────────────────────────────────────────────────────
// Vertical: top section = media + desc + votes + reactions side-by-side.
// Bottom section = full-width comment thread (conversation under each feature).
function FeatureRow({ feature, user, canManage, canCommentManage, onVote, onReact, onComment, onDelete, onEdit, onEditComment, onDeleteComment, onZoom, onRequestDraw }) {
const onZoomWithId = (src) => onZoom(src, feature.id);
return (
);
}
// ── Add/Edit feature modal ────────────────────────────────────────────
const VIDEO_MAX_BYTES = 5 * 1024 * 1024;
function FeatureModal({ initial, onClose, onSave, noun }) {
const label = noun || 'feature';
const [title, setTitle] = useState(initial?.title || '');
const [desc, setDesc] = useState(initial?.description || '');
const [media, setMedia] = useState(() => {
if (initial?.media?.length) return initial.media;
if (initial?.images?.length) return initial.images.map((src) => ({ type: 'image', src }));
return [];
});
const fileRef = useRef(null);
const onPick = async (e) => {
const files = [...e.target.files];
e.target.value = '';
for (const f of files) {
if (f.type.startsWith('image/')) {
const raw = await fileToDataURL(f);
const small = await compressImage(raw);
setMedia((p) => [...p, { type: 'image', src: small }]);
} else if (f.type.startsWith('video/')) {
if (f.size > VIDEO_MAX_BYTES) {
alert(`Video too large (${(f.size/1024/1024).toFixed(1)} MB). Max ${VIDEO_MAX_BYTES/1024/1024} MB so it fits in browser storage.`);
continue;
}
const url = await fileToDataURL(f);
setMedia((p) => [...p, { type: 'video', src: url }]);
}
}
};
const save = () => {
if (!title.trim()) return;
// Backend stores media as String[]; flatten {type,src} object entries
// to bare data URLs. The data URL itself carries the mime type so
// render-time detection (mediaIsVideo) still works.
const flat = media.map((m) => mediaSrc(m)).filter(Boolean);
onSave({ title: title.trim(), description: desc.trim(), media: flat });
};
return (
e.stopPropagation()}>
{initial ? `Edit ${label}` : `New ${label}`}
Title
setTitle(e.target.value)} placeholder={label === 'suggestion' ? 'e.g. Add a quick-react keyboard shortcut' : 'e.g. Bulk schedule from CSV'} autoFocus />
Description
);
}
// ── Drawing modal ─────────────────────────────────────────────────────
function DrawModal({ onClose, onSave }) {
const canvasRef = useRef(null);
const [color, setColor] = useState('#06b6d4');
const [size, setSize] = useState(3);
const drawing = useRef(false);
const last = useRef({ x: 0, y: 0 });
const colors = ['#1e293b', '#06b6d4', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'];
useEffect(() => {
const c = canvasRef.current;
const ctx = c.getContext('2d');
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, c.width, c.height);
}, []);
const pos = (e) => {
const c = canvasRef.current;
const rect = c.getBoundingClientRect();
const x = ((e.clientX ?? e.touches?.[0]?.clientX) - rect.left) * (c.width / rect.width);
const y = ((e.clientY ?? e.touches?.[0]?.clientY) - rect.top) * (c.height / rect.height);
return { x, y };
};
const down = (e) => { drawing.current = true; last.current = pos(e); };
const move = (e) => {
if (!drawing.current) return;
const c = canvasRef.current;
const ctx = c.getContext('2d');
const p = pos(e);
ctx.strokeStyle = color;
ctx.lineWidth = size;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(last.current.x, last.current.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
last.current = p;
};
const up = () => { drawing.current = false; };
const clear = () => {
const c = canvasRef.current;
const ctx = c.getContext('2d');
ctx.fillStyle = '#f8fafc';
ctx.fillRect(0, 0, c.width, c.height);
};
const save = () => {
const url = canvasRef.current.toDataURL('image/png');
onSave(url);
onClose();
};
return (
e.stopPropagation()}>
Draw
{ e.preventDefault(); down(e); }}
onTouchMove={(e) => { e.preventDefault(); move(e); }}
onTouchEnd={up}
/>
Cancel
Attach drawing
);
}
// ── Lightbox (with optional Annotate action) ──────────────────────────
function Lightbox({ lightbox, onClose, onAnnotate }) {
if (!lightbox) return null;
const src = typeof lightbox === 'string' ? lightbox : lightbox.src;
const featureId = typeof lightbox === 'string' ? null : lightbox.featureId;
return (
e.stopPropagation()} />
{onAnnotate && featureId && (
e.stopPropagation()}>
onAnnotate(src, featureId)}>
annotate · share your thoughts
)}
);
}
// ── Annotate modal (skeleton — pen / highlight / stamps on top of image) ─
function AnnotateModal({ imageSrc, onClose, onSave }) {
const canvasRef = React.useRef(null);
const [tool, setTool] = useState('pen');
const [color, setColor] = useState('#ef4444');
const [size, setSize] = useState(4);
const drawing = React.useRef(false);
const last = React.useRef({ x: 0, y: 0 });
// Load image to canvas on mount
const drawBaseImage = () => new Promise((res) => {
const c = canvasRef.current;
const img = new Image();
img.onload = () => {
const maxDim = 1280;
const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
c.width = Math.round(img.naturalWidth * scale);
c.height = Math.round(img.naturalHeight * scale);
const ctx = c.getContext('2d');
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(img, 0, 0, c.width, c.height);
res();
};
img.onerror = () => res();
img.src = imageSrc;
});
React.useEffect(() => { drawBaseImage(); /* eslint-disable-next-line */ }, [imageSrc]);
const pos = (e) => {
const c = canvasRef.current;
const rect = c.getBoundingClientRect();
const x = ((e.clientX ?? e.touches?.[0]?.clientX) - rect.left) * (c.width / rect.width);
const y = ((e.clientY ?? e.touches?.[0]?.clientY) - rect.top) * (c.height / rect.height);
return { x, y };
};
const placeStamp = (p) => {
const ctx = canvasRef.current.getContext('2d');
const stampSize = size * 8 + 20;
ctx.save();
ctx.font = `bold ${stampSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
ctx.shadowColor = 'rgba(0,0,0,0.45)';
ctx.shadowBlur = 6;
const glyph =
tool === 'stamp-check' ? '✓' :
tool === 'stamp-x' ? '✕' :
tool === 'stamp-q' ? '?' :
tool === 'stamp-star' ? '★' : '';
ctx.fillText(glyph, p.x, p.y);
ctx.restore();
};
const down = (e) => {
const p = pos(e);
if (tool.startsWith('stamp-')) { placeStamp(p); return; }
drawing.current = true;
last.current = p;
};
const move = (e) => {
if (!drawing.current || tool.startsWith('stamp-')) return;
const ctx = canvasRef.current.getContext('2d');
const p = pos(e);
ctx.strokeStyle = color;
ctx.lineWidth = tool === 'highlight' ? size * 4 : size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.globalAlpha = tool === 'highlight' ? 0.35 : 1;
ctx.beginPath();
ctx.moveTo(last.current.x, last.current.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
ctx.globalAlpha = 1;
last.current = p;
};
const up = () => { drawing.current = false; };
const clear = () => { drawBaseImage(); };
const save = () => {
const url = canvasRef.current.toDataURL('image/png');
onSave(url);
onClose();
};
const tools = [
{ id: 'pen', label: 'pen' },
{ id: 'highlight', label: 'highlight' },
{ id: 'stamp-check', label: '✓ check' },
{ id: 'stamp-x', label: '✕ x' },
{ id: 'stamp-q', label: '? mark' },
{ id: 'stamp-star', label: '★ star' },
];
const colors = ['#ef4444', '#4ade80', '#fbbf24', '#3b82f6', '#ffffff', '#1e293b'];
return (
e.stopPropagation()}>
annotate · show me your thoughts
{ e.preventDefault(); down(e); }}
onTouchMove={(e) => { e.preventDefault(); move(e); }}
onTouchEnd={up}
/>
tool
{tools.map((t) => (
setTool(t.id)}>
{t.label}
))}
color
{colors.map((c) => (
setColor(c)} aria-label={c} />
))}
size
setSize(+e.target.value)} />
{size}px
reset
Cancel
Post annotation
);
}
// ── Tabs (clean: tabs left · contextual actions right) ───────────────
function Tabs({ active, onChange, canCreateFeature, onNewFeature, onNewSuggestion }) {
return (
onChange('poll')}>
feature poll
onChange('suggestions')}>
suggestions
{active === 'poll' && canCreateFeature && (
new feature
)}
{active === 'suggestions' && (
new suggestion
)}
);
}
Object.assign(window, {
Wordmark, TopNav, IdentityScreen, FeatureRow, FeatureModal, DrawModal,
Lightbox, AnnotateModal, Icon, PollHero, Tabs, FaceIcon, FACES,
relTime, fileToDataURL, compressImage,
});