// 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) => ( ))}
); } // ── 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 (
schedii
marco-poliio
); } // ── TopNav ──────────────────────────────────────────────────────────── function TopNav({ user, onLogout }) { return (
{user && ( <>
{user.avatar ? : {user.name[0].toUpperCase()}} {user.name} {user.role}
)}
); } // ── 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 (

name · face · password

{ setName(e.target.value); setErr(''); }} placeholder="how others see you" autoFocus maxLength={40} />
{ setPassword(e.target.value); setErr(''); }} placeholder="(blank)" autoComplete="off" style={{ flex: 1 }} />
{err}
); } // ── 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 && (
)} {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) ?
); })}
)}
); } // ── Description card (right — top) ──────────────────────────────────── function DescCard({ feature, canManage, onEdit }) { return (
{canManage && (
)}
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 (
); } // ── 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 && ( )}
{editing ? (