// fc-expanded.jsx — ExpandedNodeView + PollSection const { useState, useRef, useEffect, useCallback } = React; // ── PollSection ──────────────────────────────────────────────────────────── function PollSection({ node, readOnly, onUpdatePoll }) { const poll = node.poll || null; // Linked nodes share the same poll (poll is a synced field), so scope the // per-browser "already voted" guard to the link group when there is one — // otherwise the same user could vote once per linked copy and inflate counts. const voteScope = node.linkGroupId || node.id; const localKey = `fc_poll_vote_${voteScope}`; const myVote = (() => { try { return localStorage.getItem(localKey); } catch { return null; } })(); const hasVoted = !!myVote; const [editing, setEditing] = useState(false); const [draftQ, setDraftQ] = useState(''); const [draftOpts, setDraftOpts] = useState([]); function startCreate() { setDraftQ(''); setDraftOpts([{ id: uid('opt'), text: '' }, { id: uid('opt'), text: '' }]); setEditing(true); } function startEdit() { if (!poll) return; setDraftQ(poll.question); setDraftOpts(poll.options.map(o => ({ ...o }))); setEditing(true); } function savePoll() { const question = draftQ.trim(); const options = draftOpts.filter(o => o.text.trim()).map(o => ({ ...o, text: o.text.trim() })); if (!question || options.length < 2) return; onUpdatePoll(node.id, { question, options, votes: poll?.votes || {} }); setEditing(false); } function deletePoll() { if (!confirm('Delete this poll? All votes will be lost.')) return; onUpdatePoll(node.id, null); try { localStorage.removeItem(localKey); } catch {} } function castVote(optionId) { if (hasVoted || !poll) return; const newVotes = { ...(poll.votes || {}), [optionId]: ((poll.votes || {})[optionId] || 0) + 1 }; onUpdatePoll(node.id, { ...poll, votes: newVotes }); try { localStorage.setItem(localKey, optionId); } catch {} } function retractVote() { if (!hasVoted || !poll) return; const prev = (poll.votes || {})[myVote] || 0; const newVotes = { ...(poll.votes || {}), [myVote]: Math.max(0, prev - 1) }; onUpdatePoll(node.id, { ...poll, votes: newVotes }); try { localStorage.removeItem(localKey); } catch {} } const totalVotes = poll ? Object.values(poll.votes || {}).reduce((s, v) => s + v, 0) : 0; if (editing) { return (
{poll ? 'Edit Poll' : 'Create Poll'}
setDraftQ(e.target.value)} placeholder="Poll question…" />
{draftOpts.map((opt, i) => (
{i + 1}. setDraftOpts(prev => prev.map((o, j) => j === i ? { ...o, text: e.target.value } : o))} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); setDraftOpts(prev => [...prev, { id: uid('opt'), text: '' }]); } }} placeholder={`Option ${i + 1}…`} style={{ flex: 1 }} /> {draftOpts.length > 2 && ( )}
))}
o.text.trim()).length < 2} onClick={savePoll} style={{ flex: 1 }}>Save Poll setEditing(false)}>Cancel
); } if (!poll) { if (readOnly) return null; return ◉ Add poll to this node; } return (
◉ {poll.question}
{!readOnly && (
)}
{poll.options.map(opt => { const votes = (poll.votes || {})[opt.id] || 0; const pct = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0; const isMyV = myVote === opt.id; const canVote = !hasVoted; return (
canVote && castVote(opt.id)} style={{ position: 'relative', overflow: 'hidden', background: isMyV ? 'var(--accent-muted)' : 'var(--bg)', border: `1.5px solid ${isMyV ? 'var(--accent)' : 'var(--border)'}`, borderRadius: 9, padding: '9px 12px', cursor: canVote ? 'pointer' : 'default', transition: 'all 0.12s', }} onMouseEnter={e => { if (canVote) e.currentTarget.style.borderColor = 'var(--accent)'; }} onMouseLeave={e => { if (canVote && !isMyV) e.currentTarget.style.borderColor = 'var(--border)'; }} > {hasVoted && (
)}
{isMyV && } {opt.text} {hasVoted && ( {pct}% ({votes}) )}
); })}
{totalVotes} vote{totalVotes !== 1 ? 's' : ''} {hasVoted ? : Click an option to vote }
); } // ── ImageGrid ────────────────────────────────────────────────────────────── function ImageGrid({ images, readOnly, nodeId, onAdd, onRemove, onOpenLightbox }) { const fileRef = useRef(null); const [dragOver, setDragOver] = useState(false); async function handleFiles(fileList) { const files = [...(fileList || [])]; for (const file of files) { if (!file.type.startsWith('image/')) continue; try { const dataUrl = (typeof readAndCompressImageFile === 'function') ? await readAndCompressImageFile(file) : await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('Read failed')); reader.readAsDataURL(file); }); onAdd(nodeId, dataUrl); } catch (err) { if (err?.message === 'too_large') alert(`"${file.name}" too large (max 4 MB)`); } } } function handleDrop(e) { e.preventDefault(); setDragOver(false); const files = [...(e.dataTransfer.files || [])].filter(f => f.type.startsWith('image/')); if (files.length) handleFiles(files); } return (
Images ({images.length}) {!readOnly && ( <> fileRef.current?.click()}>+ Add image { handleFiles(e.target.files); e.target.value = ''; }} /> )}
{images.length > 0 ? (
{images.map((src, i) => (
onOpenLightbox(i)}> {!readOnly && ( )}
))} {/* Drop zone tile */} {!readOnly && (
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} onClick={() => fileRef.current?.click()} style={{ paddingBottom: '75%', borderRadius: 8, position: 'relative', border: `1.5px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`, background: dragOver ? 'var(--accent-muted)' : 'transparent', cursor: 'pointer', transition: 'all 0.15s', }}>
+
)}
) : (
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={handleDrop} onClick={() => !readOnly && fileRef.current?.click()} style={{ border: `1.5px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`, background: dragOver ? 'var(--accent-muted)' : 'transparent', borderRadius: 10, padding: '28px 16px', textAlign: 'center', color: 'var(--text2)', fontSize: 12, lineHeight: 1.6, cursor: readOnly ? 'default' : 'pointer', transition: 'all 0.15s', }} > {readOnly ? 'No images attached' : (dragOver ? 'Drop to upload' : 'Drop images here, paste (Ctrl+V), or click to upload')}
)}
); } // ── Lightbox ─────────────────────────────────────────────────────────────── function Lightbox({ images, startIndex, onClose }) { const [idx, setIdx] = useState(startIndex); useEffect(() => { function onKey(e) { if (e.key === 'Escape') onClose(); if (e.key === 'ArrowRight' || e.key === 'ArrowDown') setIdx(i => Math.min(images.length - 1, i + 1)); if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') setIdx(i => Math.max(0, i - 1)); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [images.length, onClose]); if (!images.length) return null; return (
e.stopPropagation()}>
{idx + 1} / {images.length}
); } // ── PollsModal ───────────────────────────────────────────────────────────── // Lists every poll across every chart, split into "To Vote" and "Voted" tabs. // Clicking a poll opens its node in the expanded view. function PollsModal({ project, onClose, onOpenNode }) { const [tab, setTab] = useState('todo'); // 'todo' | 'voted' // Gather every poll-bearing node from every chart const allPolls = []; (project?.charts || []).forEach(c => { (c.nodes || []).forEach(n => { if (n.poll) allPolls.push({ chart: c, node: n }); }); }); function myVoteFor(node) { // Mirror PollSection's link-group-aware vote scope. const scope = node.linkGroupId || node.id; try { return localStorage.getItem(`fc_poll_vote_${scope}`); } catch { return null; } } const todoPolls = allPolls.filter(p => !myVoteFor(p.node)); const votedPolls = allPolls.filter(p => myVoteFor(p.node)); const list = tab === 'todo' ? todoPolls : votedPolls; return (
{ if (e.target === e.currentTarget) onClose(); }} >
{/* Header */}
◉ Polls {allPolls.length} total
{/* Tab strip */}
{[ { id: 'todo', label: 'To Vote', count: todoPolls.length, color: '#f59e0b' }, { id: 'voted', label: 'Voted', count: votedPolls.length, color: '#22c55e' }, ].map(t => { const active = tab === t.id; return ( ); })}
{/* List */}
{list.length === 0 ? (
{tab === 'todo' ? 'No polls waiting' : 'No polls voted yet'}
{tab === 'todo' ? 'You\'ve voted on every poll. Nice.' : 'Cast a vote and it\'ll show up here.'}
) : list.map(({ chart, node }) => { const poll = node.poll; const myVote = myVoteFor(node); const myOpt = myVote ? poll.options.find(o => o.id === myVote) : null; const totalVotes = Object.values(poll.votes || {}).reduce((s, v) => s + v, 0); const leading = poll.options.reduce((best, o) => { const v = (poll.votes || {})[o.id] || 0; return v > best.v ? { o, v } : best; }, { o: null, v: -1 }); return ( ); })}
); } // ── ExpandedNodeView ─────────────────────────────────────────────────────── function ExpandedNodeView({ node, chart, nodeTypes, statuses, readOnly, onClose, onUpdateNode, onUpdatePoll, onDeleteNode, onOpenSubChart, onCreateSubChart, onAddImage, onRemoveImage }) { if (!node) return null; const type = nodeTypes.find(t => t.id === node.typeId) || { color: '#94a3b8', name: 'Generic' }; const [lightboxIdx, setLightboxIdx] = useState(null); const [detailsMode, setDetailsMode] = useState('edit'); // 'edit' | 'preview' const [tagInput, setTagInput] = useState(''); // ── Local mirrors for fast-typing-friendly text fields ── // Controlled inputs were reverting on remote sync echos. We keep a local // copy that only adopts the incoming value when: // (a) the node id changes, or // (b) the incoming value differs AND the field isn't currently focused. // Edits flush to the parent on each keystroke (same as before) so live-sync // and undo/redo still work — but our display value never goes backwards mid-typing. const [localLabel, setLocalLabel] = useState(node.label || ''); const [localNotes, setLocalNotes] = useState(node.notes || ''); const [localDetails, setLocalDetails] = useState(node.details || ''); const labelRef = useRef(null); const notesRef = useRef(null); const detailsRef = useRef(null); const lastNodeIdRef = useRef(node.id); useEffect(() => { const isNewNode = lastNodeIdRef.current !== node.id; lastNodeIdRef.current = node.id; const incomingLabel = node.label || ''; const incomingNotes = node.notes || ''; const incomingDetails = node.details || ''; const active = document.activeElement; if (isNewNode || (incomingLabel !== localLabel && active !== labelRef.current)) setLocalLabel(incomingLabel); if (isNewNode || (incomingNotes !== localNotes && active !== notesRef.current)) setLocalNotes(incomingNotes); if (isNewNode || (incomingDetails !== localDetails && active !== detailsRef.current)) setLocalDetails(incomingDetails); }, [node.id, node.label, node.notes, node.details]); function upNode(changes) { onUpdateNode(node.id, changes); } function addTag(e) { if (e.key !== 'Enter' && e.key !== ',') return; e.preventDefault(); const val = tagInput.trim().replace(/^#/, '').toLowerCase(); if (!val) return; const cur = node.tags || []; if (!cur.includes(val)) upNode({ tags: [...cur, val] }); setTagInput(''); } async function handlePaste(e) { const items = e.clipboardData?.items || []; for (const item of items) { if (item.type.startsWith('image/')) { const blob = item.getAsFile(); if (!blob) continue; try { const dataUrl = (typeof readAndCompressImageFile === 'function') ? await readAndCompressImageFile(blob) : await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('Read failed')); reader.readAsDataURL(blob); }); onAddImage(node.id, dataUrl); } catch (err) { if (err?.message === 'too_large') alert('Pasted image too large (max 4 MB)'); } } } } useEffect(() => { function onKey(e) { if (e.key === 'Escape' && lightboxIdx === null) onClose(); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, lightboxIdx]); const detailsWords = (localDetails || '').trim().split(/\s+/).filter(Boolean).length; const detailsChars = (localDetails || '').length; // Simple markdown-style preview: bold **x**, italic *x*, headings ## x, code `x` function renderPreview(text) { if (!text) return No details yet.; const lines = text.split('\n'); return lines.map((line, i) => { let el; if (/^## (.+)/.test(line)) el = {line.replace(/^## /, '')}; else if (/^# (.+)/.test(line)) el = {line.replace(/^# /, '')}; else if (/^- (.+)/.test(line)) el =
• {line.replace(/^- /, '')}
; else if (line.trim() === '') el =
; else el =
{line}
; return {el}; }); } return (
{ if (e.target === e.currentTarget) onClose(); }} onPaste={handlePaste} >
e.stopPropagation()} > {/* Header */}
{type.name} {(node.tags || []).map((tag, i) => ( #{tag} ))} Double-click node · Enter to open
{/* Body */}
{/* Left column */}
{/* Title */} { setLocalLabel(e.target.value); upNode({ label: e.target.value }); }} placeholder="Title…" style={{ background: 'transparent', border: 'none', outline: 'none', fontSize: 26, fontWeight: 800, color: 'var(--text)', fontFamily: 'inherit', padding: '2px 0', borderBottom: '1.5px solid transparent', width: '100%', }} onFocus={e => { e.currentTarget.style.borderBottomColor = 'var(--border)'; }} onBlur={e => { e.currentTarget.style.borderBottomColor = 'transparent'; }} /> {/* Summary / notes */}
Summary