// Deliverables view — multi-project document center. // Each "project" is a draft of a template (program plan, check-in, poster, résumé, etc.). // You can keep many projects in flight, switch between them, version-rollback, // drag in references from your Bibliography, embed images, render math, // and export to Markdown / LaTeX / HTML / Print. // AI passes are stubbed (need LLM endpoint) but every static signal works today. import React, { useState, useMemo, useEffect, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import rehypeKatex from 'rehype-katex'; import 'katex/dist/katex.min.css'; import Icon from './CanvasIcon'; import { MOD } from './platform'; import LatexEditor from './CanvasLatexEditor'; // Markdown plugins shared across all rendered blocks. remark-math + rehype-katex // give us real LaTeX math (`$...$` inline, `$$...$$` block) inside any preview. const REMARK_PLUGINS = [remarkGfm, remarkMath]; const REHYPE_PLUGINS = [rehypeKatex]; const fireToast = (msg, kind = 'success') => window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } })); const STORE_KEY = 'canvas-deliverables-v2'; const MAX_VERSIONS = 10; const newId = (p) => p + Math.random().toString(36).slice(2, 8); // ============================================================================ // Templates // ============================================================================ export const TEMPLATES = [ { id: 'research-paper', name: 'Internship Search Plan', desc: 'Goals → Target roles → Channels → Weekly cadence → Materials → Tracking', icon: 'book', mode: 'paper', sections: [ { id: 'abstract', name: 'Overview', target: 250, hint: 'One paragraph: academic standing, target term, role lane, and success metric.', checks: ['hasNumber', 'hasFinding'] }, { id: 'intro', name: 'Goals & constraints', target: 1000, hint: 'Internship vs FT, location, unpaid OK?, hours/week available.', checks: ['hasNumber'] }, { id: 'methods', name: 'Target roles & companies', target: 800, hint: 'Primary/backup lanes and a short company shortlist.', checks: [] }, { id: 'results', name: 'Weekly cadence', target: 800, hint: 'Apply blocks, volume targets, and deadline handling.', checks: ['hasNumber'] }, { id: 'discussion', name: 'Materials checklist', target: 1000, hint: 'Resume, LinkedIn & About Me, STAR stories, outreach scripts.', checks: [] }, { id: 'refs', name: 'Tracking system', target: 0, hint: 'Board columns, metrics to review every Friday.', checks: [] }, ], }, { id: 'thesis-chapter', name: 'Weekly Search Review', desc: 'Overview → What you applied to → Callbacks → What to change', icon: 'book', mode: 'paper', sections: [ { id: 'overview', name: 'Overview', target: 200, hint: 'What week this covers and the goal.', checks: [] }, { id: 'background', name: 'Applications sent', target: 1500, hint: 'Companies, roles, and channels used.', checks: ['hasNumber'] }, { id: 'methods', name: 'Outreach & prep', target: 1500, hint: 'Alumni notes, career-fair follow-ups, interview practice.', checks: ['hasNumber'] }, { id: 'results', name: 'Results', target: 2000, hint: 'Screens, interviews, and feedback received.', checks: ['hasNumber'] }, { id: 'discussion', name: 'What to change', target: 1500, hint: 'What worked, what did not, next week adjustments.', checks: ['hasLimit'] }, ], }, { id: 'nsf-grfp', name: 'Career Intake', desc: 'Student background + goal plan for advising.', icon: 'award', mode: 'document', sections: [ { id: 'personal', name: 'Student background', target: 1500, hint: 'Major, year, experience, and motivation. Write as a story.', checks: [] }, { id: 'research', name: 'Goal plan', target: 1000, hint: 'Specific target (role/term) and how you will get there.', checks: ['hasNumber'] }, ], }, { id: 'conference-abstract', name: 'Weekly Check-in', desc: 'Single section, ~250 words. Lead with pipeline numbers.', icon: 'send', mode: 'document', sections: [ { id: 'abs', name: 'Check-in', target: 250, hint: 'One paragraph: apps sent, interviews, blockers, next focus.', checks: ['hasFinding', 'hasNumber'] }, ], }, { id: 'defense-slides', name: 'Search Presentation', desc: 'Title → Goals → Targets → Cadence → Materials → Risks → Q&A', icon: 'kanban', mode: 'slides', sections: [ { id: 'title', name: 'Title slide', target: 30, hint: 'Search name, your name, date.', checks: [] }, { id: 'outline', name: 'Outline', target: 60, hint: '5–7 bullets covering the plan.', checks: [] }, { id: 'background', name: 'Background', target: 200, hint: 'Standing, major, and why this lane.', checks: [] }, { id: 'question', name: 'Goal', target: 80, hint: 'Single measurable goal for the term.', checks: [] }, { id: 'methods', name: 'Approach', target: 200, hint: 'Channels, cadence, and outreach plan.', checks: ['hasNumber'] }, { id: 'results', name: 'Expected results', target: 300, hint: 'Realistic pipeline targets by month.', checks: ['hasNumber'] }, { id: 'discussion', name: 'Risks', target: 200, hint: 'Time conflicts, thin experience, location limits.', checks: ['hasLimit'] }, { id: 'qa', name: 'Anticipated Q&A', target: 300, hint: 'Hardest 5 questions and your answers.', checks: [] }, ], }, { id: 'poster', name: 'Pipeline Poster', desc: '4-quadrant board: Goal · Approach · Results · Next.', icon: 'layout', mode: 'poster', sections: [ { id: 'title', name: 'Title & Student', target: 30, hint: 'Search focus, your name, term.', checks: [] }, { id: 'intro', name: 'Goal', target: 200, hint: 'Where you started and where you are headed.', checks: [] }, { id: 'methods', name: 'Approach', target: 200, hint: 'Channels, cadence, materials.', checks: ['hasNumber'] }, { id: 'results', name: 'Results', target: 250, hint: 'Headline pipeline numbers.', checks: ['hasNumber'] }, { id: 'discussion', name: 'Next steps', target: 200, hint: 'What it means + what is next.', checks: ['hasLimit'] }, { id: 'refs', name: 'Notes / Credits', target: 80, hint: 'Advisors, career services, links.', checks: [] }, ], }, { id: 'cv', name: 'Internship Résumé', desc: 'Standard sections: Education · Experience · Projects · Leadership · Skills.', icon: 'user', mode: 'document', sections: [ { id: 'header', name: 'Header', target: 40, hint: 'Name, email, LinkedIn, city, phone.', checks: [] }, { id: 'education', name: 'Education', target: 100, hint: 'School · Major · Expected grad · GPA (if strong).', checks: [] }, { id: 'publications', name: 'Experience', target: 300, hint: 'Jobs/internships with action + metric bullets.', checks: ['hasNumber'] }, { id: 'talks', name: 'Projects', target: 150, hint: 'Class or personal projects with tools and outcomes.', checks: [] }, { id: 'awards', name: 'Leadership & activities', target: 100, hint: 'Clubs, mentoring, volunteer roles.', checks: [] }, { id: 'service', name: 'Relevant coursework', target: 100, hint: 'Courses that match the target role.', checks: [] }, { id: 'skills', name: 'Skills', target: 60, hint: 'Tools, languages, and platforms.', checks: [] }, ], }, { id: 'cover-letter', name: 'Cover Letter / Outreach', desc: 'For applications or alumni/recruiter outreach.', icon: 'send', mode: 'document', sections: [ { id: 'header', name: 'Header', target: 40, hint: 'Date, recipient, greeting.', checks: [] }, { id: 'opener', name: 'Opening paragraph', target: 100, hint: 'Role + why you are reaching out.', checks: [] }, { id: 'body', name: 'Fit & proof', target: 250, hint: '2–3 proof points tied to the JD. Numbers > adjectives.', checks: ['hasNumber'] }, { id: 'fit', name: 'Why this org', target: 150, hint: 'Specific company/team reason.', checks: [] }, { id: 'close', name: 'Close', target: 60, hint: 'Thanks + clear next step + signature.', checks: [] }, ], }, { id: 'linkedin-about', name: 'LinkedIn & About Me', desc: 'Headline, About, and experience blurbs employers can scan in 30 seconds.', icon: 'user', mode: 'document', sections: [ { id: 'framing', name: 'Framing choices', target: 80, hint: 'Rewrite vs polish · internship vs FT · tone (warm / crisp / technical).', checks: [] }, { id: 'about-me', name: 'About Me narrative', target: 200, hint: 'Who you are, what you want, and proof points — plain language employers can skim.', checks: [] }, { id: 'headline', name: 'LinkedIn headline', target: 40, hint: 'Role lane + proof + target (≤220 chars). No buzzword soup.', checks: [] }, { id: 'about', name: 'LinkedIn About', target: 300, hint: '3–5 short paragraphs or bullets tied to About Me. Scannable for recruiters.', checks: ['hasNumber'] }, { id: 'experience', name: 'Experience blurbs', target: 250, hint: 'Top 1–2 roles/projects with action + outcome lines matching your resume.', checks: ['hasNumber'] }, { id: 'alignment', name: 'Resume ↔ LinkedIn check', target: 100, hint: 'Same story, titles, and tools — no invented metrics.', checks: [] }, ], }, { id: 'irb-protocol', name: 'Interview Prep Sheet', desc: 'Company research, likely questions, and STAR answers.', icon: 'shield', mode: 'document', sections: [ { id: 'overview', name: 'Interview overview', target: 200, hint: 'Company, role, format (phone/virtual/onsite), date.', checks: [] }, { id: 'background', name: 'Company research', target: 400, hint: 'Product, recent news, values, and why you care.', checks: [] }, { id: 'aims', name: 'Role must-haves', target: 250, hint: 'Keywords and requirements from the JD.', checks: [] }, { id: 'participants', name: 'Your proof points', target: 300, hint: 'Match each must-have to a story or project.', checks: ['hasNumber'] }, { id: 'procedures', name: 'STAR answers', target: 500, hint: 'Write 3–5 full STAR answers you will practice aloud.', checks: ['hasNumber'] }, { id: 'risks', name: 'Hard questions', target: 200, hint: 'Gaps, GPA, job hopping, “tell me about a failure.”', checks: ['hasLimit'] }, { id: 'benefits', name: 'Questions to ask them', target: 100, hint: '3 smart questions that show research.', checks: [] }, { id: 'consent', name: 'Logistics', target: 200, hint: 'Outfit, tech check, arrival plan, thank-you note draft.', checks: [] }, { id: 'data', name: 'Post-interview notes', target: 200, hint: 'What went well, what to improve, follow-up date.', checks: [] }, ], }, { id: 'meeting-prep', name: 'Advisor / Recruiter Meeting Prep', desc: 'Agenda, updates, decisions needed, follow-ups.', icon: 'message', mode: 'document', sections: [ { id: 'agenda', name: 'Agenda', target: 80, hint: '3–5 bullets ranked by priority.', checks: [] }, { id: 'progress', name: 'Progress since last meeting', target: 200, hint: 'Apps, interviews, outreach — with numbers.', checks: ['hasNumber'] }, { id: 'blockers', name: 'Blockers', target: 150, hint: 'What is getting in the way.', checks: ['hasLimit'] }, { id: 'decisions', name: 'Decisions needed', target: 200, hint: 'Frame as A/B options with your preference.', checks: [] }, { id: 'questions', name: 'Questions', target: 150, hint: 'Open questions for the advisor/recruiter.', checks: [] }, { id: 'followup', name: 'Action items', target: 100, hint: 'What to do + by when.', checks: [] }, ], }, ]; // ============================================================================ // Slash command catalog — typed at the start of a line in any section editor. // ============================================================================ const SLASH_COMMANDS = [ { id: 'h2', label: 'Heading', kind: 'block', icon: 'list', insert: () => '## Heading\n' }, { id: 'h3', label: 'Subheading', kind: 'block', icon: 'list', insert: () => '### Subheading\n' }, { id: 'list', label: 'Bullet list', kind: 'block', icon: 'list', insert: () => '- ' }, { id: 'todo', label: 'To-do', kind: 'block', icon: 'task', insert: () => '- [ ] ' }, { id: 'numbered', label: 'Numbered list', kind: 'block', icon: 'list', insert: () => '1. ' }, { id: 'quote', label: 'Quote', kind: 'block', icon: 'cite', insert: () => '> ' }, { id: 'callout', label: 'Callout', kind: 'block', icon: 'sparkles', insert: () => '> [!note]\n> ' }, { id: 'divider', label: 'Divider', kind: 'block', icon: 'list', insert: () => '\n---\n\n' }, { id: 'code', label: 'Code block', kind: 'block', icon: 'flask', insert: () => '```\n\n```\n' }, { id: 'math', label: 'Equation', kind: 'block', icon: 'flask', insert: () => '$$\nE = mc^2\n$$\n' }, { id: 'inline-math', label: 'Inline equation', kind: 'inline', icon: 'flask', insert: () => '$x^2$' }, { id: 'image', label: 'Image (paste URL)', kind: 'block', icon: 'download', insert: () => '![caption](https://)' }, { id: 'cite', label: 'Citation @key', kind: 'inline', icon: 'book', insert: () => '(@key)' }, { id: 'bold', label: 'Bold', kind: 'inline', icon: 'pencil', insert: () => '**bold**' }, { id: 'italic', label: 'Italic', kind: 'inline', icon: 'pencil', insert: () => '*italic*' }, ]; // ============================================================================ // Static "missing" checks // ============================================================================ const CHECKS = { hasNumber: { test: (s) => /\d/.test(s), label: 'Mentions at least one number' }, hasCitation: { test: (s) => /@\w+/.test(s), label: 'Cites at least one source (@key)' }, hasFinding: { test: (s) => /\b(we (find|show|report|demonstrate)|finding|result)/i.test(s), label: 'States a finding' }, hasGap: { test: (s) => /\b(gap|lack|missing|unknown|unclear|despite)/i.test(s), label: 'Names a gap' }, hasFigure: { test: (s) => /\b(fig(ure)?|table)\.?\s*\d/i.test(s), label: 'References a figure or table' }, hasLimit: { test: (s) => /\b(limit|caveat|however|future work|did not|cannot)/i.test(s), label: 'Acknowledges a limit' }, hasBroaderImpacts: { test: (s) => /\b(broader impact|outreach|community|underrepresented|access|teaching)/i.test(s), label: 'Addresses broader impacts' }, hasHypothesis: { test: (s) => /\b(hypothes|predict|aim ?\d)/i.test(s), label: 'States a hypothesis or aim' }, hasMerit: { test: (s) => /\b(intellectual merit|novel|advances|contribut)/i.test(s), label: 'Frames intellectual merit' }, }; const wordCount = (s) => (s || '').trim().split(/\s+/).filter(Boolean).length; const readingMinutes = (n) => Math.max(1, Math.round(n / 220)); // ============================================================================ // Exporters // ============================================================================ const exportMarkdown = (template, sections) => [ `# ${template.name}\n`, ...template.sections.map(s => `## ${s.name}\n\n${sections[s.id] || ''}\n`), ].join('\n'); const exportLatex = (template, sections) => [ '\\documentclass{article}', `\\title{${template.name}}`, '\\begin{document}', '\\maketitle', ...template.sections.map(s => `\n\\section{${s.name}}\n${sections[s.id] || ''}\n`), '\\end{document}', ].join('\n'); const exportHtml = (template, sections) => [ '', `${template.name}`, `

${template.name}

`, ...template.sections.map(s => `

${s.name}

${(sections[s.id] || '').replace(/\n/g, '
')}

`), '', ].join('\n'); const downloadFile = (filename, mime, contents) => { const blob = new Blob([contents], { type: mime }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); }; // ============================================================================ // Project store — multi-project (was: single-template). Migrates v1 if found. // ============================================================================ const loadStore = () => { try { const v2 = JSON.parse(localStorage.getItem(STORE_KEY) || 'null'); if (v2) return v2; // Migrate v1: turn each templateId entry into a project. const v1 = JSON.parse(localStorage.getItem('canvas-deliverables-v1') || '{}'); if (v1 && v1.templates) { const projects = {}; let activeId; Object.entries(v1.templates).forEach(([tid, sec]) => { const id = newId('p-'); const { _aiNotes, ...rest } = sec; projects[id] = { id, name: TEMPLATES.find(t => t.id === tid)?.name || tid, templateId: tid, sections: rest, versions: [], aiNotes: _aiNotes || null, createdAt: Date.now(), }; if (tid === v1.activeTemplateId) activeId = id; }); return { activeProjectId: activeId, projects }; } } catch { /* fallthrough */ } return { activeProjectId: null, projects: {} }; }; // ============================================================================ // Main view // ============================================================================ const DeliverablesView = ({ allStates }) => { const [store, setStore] = useState(loadStore); useEffect(() => { localStorage.setItem(STORE_KEY, JSON.stringify(store)); }, [store]); const project = store.projects[store.activeProjectId] || null; const template = project ? TEMPLATES.find(t => t.id === project.templateId) : null; const sections = project?.sections || {}; const [activeSectionId, setActiveSectionId] = useState(template?.sections[0]?.id); const [generatingAi, setGeneratingAi] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); // Normalize active section when project/template changes useEffect(() => { if (!template) return; const valid = template.sections.some(s => s.id === activeSectionId); if (!valid) setActiveSectionId(template.sections[0].id); }, [project?.id, template, activeSectionId]); // ---------- Project ops ---------- const createProject = (templateId, name) => { const id = newId('p-'); const t = TEMPLATES.find(x => x.id === templateId); setStore(s => ({ activeProjectId: id, projects: { ...s.projects, [id]: { id, name: name || `${t.name} draft`, templateId, sections: {}, versions: [], aiNotes: null, createdAt: Date.now() }, }, })); fireToast(`New ${t.name} draft created`); }; const switchProject = (id) => setStore(s => ({ ...s, activeProjectId: id })); const renameProject = (name) => { if (!project) return; setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], name } } })); }; const deleteProject = () => { if (!project) return; if (!window.confirm(`Delete "${project.name}"? This can't be undone.`)) return; setStore(s => { const { [project.id]: _, ...rest } = s.projects; const nextActive = Object.keys(rest)[0] || null; return { activeProjectId: nextActive, projects: rest }; }); }; const closeProject = () => setStore(s => ({ ...s, activeProjectId: null })); // ---------- Section ops + auto-versioning ---------- const updateSection = (id, value) => { setStore(s => { const proj = s.projects[project.id]; const oldText = proj.sections[id] || ''; // Snapshot a version every time a section gains/loses ≥80 chars (rough save cadence) const shouldSnap = Math.abs(value.length - oldText.length) >= 80; const newVersions = shouldSnap ? [{ at: Date.now(), sectionId: id, snapshot: { ...proj.sections } }, ...proj.versions].slice(0, MAX_VERSIONS) : proj.versions; return { ...s, projects: { ...s.projects, [project.id]: { ...proj, sections: { ...proj.sections, [id]: value }, versions: newVersions }, }, }; }); }; const restoreVersion = (v) => { if (!window.confirm('Restore this version? Current text will be replaced.')) return; setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], sections: v.snapshot } }, })); fireToast('Restored'); }; // ---------- AI pass (stub) ---------- // TODO(LLM): POST {project, sections, canvas} → {notes:[{sectionId,msg}]} const runAiPass = () => { setGeneratingAi(true); setTimeout(() => { const notes = template.sections.map(s => { const text = sections[s.id] || ''; const wc = wordCount(text); if (wc === 0) return { sectionId: s.id, msg: `Empty — start with: "${s.hint}"` }; if (wc < s.target * 0.3) return { sectionId: s.id, msg: `Thin (${wc} words). Target ${s.target}.` }; return { sectionId: s.id, msg: `Looks reasonable for length (${wc} words). LLM-pass would suggest specifics here.` }; }); setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], aiNotes: notes } } })); setGeneratingAi(false); fireToast('AI pass complete (stub)'); }, 700); }; // ---------- Insertables: Bibliography + Highlights + Outline + Drafts + arXiv search ---------- const localInsertables = useMemo(() => { const items = []; (allStates?.bibliography?.entries || []).forEach(e => items.push({ kind: 'cite', label: e.title, snippet: ` (${e.authors}, ${e.year}; @${e.key})` })); (allStates?.highlights?.items || []).forEach(h => items.push({ kind: 'quote', label: h.text.slice(0, 60), snippet: `"${h.text}"${h.citeKey ? ` (@${h.citeKey})` : ''}` })); (allStates?.outline?.items || []).forEach(o => items.push({ kind: 'outline', label: o.text || '(empty)', snippet: '\n' + ' '.repeat(o.depth) + '- ' + (o.text || '') })); (allStates?.writing?.chapters || []).forEach(c => items.push({ kind: 'draft', label: c.name, snippet: c.draft || '' })); return items; }, [allStates]); const insertIntoActive = (snippet) => { if (!activeSectionId) return; const cur = sections[activeSectionId] || ''; updateSection(activeSectionId, cur + (cur && !cur.endsWith('\n') ? ' ' : '') + snippet); fireToast('Inserted into ' + template.sections.find(s => s.id === activeSectionId)?.name); }; // ---------- Export ---------- const exportAs = (format) => { const filename = `${project.name.replace(/\s+/g, '_')}.${format === 'latex' ? 'tex' : format === 'markdown' ? 'md' : 'html'}`; const mime = format === 'html' ? 'text/html' : 'text/plain'; const contents = format === 'markdown' ? exportMarkdown(template, sections) : format === 'latex' ? exportLatex(template, sections) : exportHtml(template, sections); downloadFile(filename, mime, contents); fireToast(`Exported ${filename}`); }; // ---------- Aggregates ---------- const totalWords = template ? template.sections.reduce((sum, s) => sum + wordCount(sections[s.id]), 0) : 0; const totalTarget = template ? template.sections.reduce((sum, s) => sum + s.target, 0) : 0; const projectList = Object.values(store.projects).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); // ---------- Empty state: project picker / template picker ---------- if (!project) { return ( <>

Documents

Documents hub — program plans, check-ins, nutrition protocols, and progress boards. Drafts auto-save. Versions kept for rollback. {projectList.length > 0 && ` · ${projectList.length} draft${projectList.length === 1 ? '' : 's'} in flight.`}
{/* Existing projects, if any */} {projectList.length > 0 && (
Continue working
Drafts you've started.
{projectList.map(p => { const t = TEMPLATES.find(t => t.id === p.templateId); if (!t) return null; const wc = t.sections.reduce((sum, s) => sum + wordCount(p.sections[s.id]), 0); const target = t.sections.reduce((sum, s) => sum + s.target, 0); return ( ); })}
)} {/* Templates */}
{projectList.length > 0 ? 'Or start a new draft' : 'Pick a template'}
13 templates · program plans, check-ins, posters, résumé, and more.
{TEMPLATES.map(t => ( ))}
{/* TODO(LLM): "Upload project brief → AI generates a custom outline" */}
From a project brief
Upload your brief and the AI will draft a custom outline. (Needs LLM endpoint — coming soon.)
); } // ============================================================================ // Editor: project tabs + header + per-mode editor // ============================================================================ const aiNotes = project.aiNotes; const ProjectTabs = (
{projectList.map(p => { const t = TEMPLATES.find(x => x.id === p.templateId); return ( ); })}
New
{TEMPLATES.map(t => ( ))}
); const Header = (
renameProject(e.target.value)} />
{template.name} · {totalWords} / {totalTarget} words · ~{readingMinutes(totalWords)} min read · {template.sections.length} {template.mode === 'slides' ? 'slides' : 'sections'}
Export
); const HistoryPanel = historyOpen && (
Version history · {project.versions.length}
{project.versions.length === 0 ? (
Snapshots auto-save every ~80 characters of edits.
) : ( project.versions.map((v, i) => { const sec = template.sections.find(s => s.id === v.sectionId); return ( ); }) )}
); const InsertPanel = (
From canvas · {localInsertables.length}
{localInsertables.length === 0 && (
Add a Bibliography, Highlights, Outline, or Writing widget to your canvas to surface its content here.
)} {localInsertables.map((it, i) => ( ))}
); // ---------- POSTER MODE — 4-quadrant + 2 banner sections ---------- if (template.mode === 'poster') { const layout = template.sections; return ( <> {ProjectTabs} {Header} {HistoryPanel}
{InsertPanel}
); } // ---------- SLIDES MODE — Google Slides feel ---------- if (template.mode === 'slides') { const activeIdx = template.sections.findIndex(s => s.id === activeSectionId); const active = template.sections[activeIdx] || template.sections[0]; const text = sections[active.id] || ''; const aiForSlide = aiNotes && aiNotes.find(n => n.sectionId === active.id); const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean); return ( <> {ProjectTabs} {Header} {HistoryPanel}
{template.sections.length} slides
{template.sections.map((s, i) => { const t = sections[s.id] || ''; const tLines = t.split(/\n+/).map(l => l.trim()).filter(Boolean); return ( ); })}
{active.name}
{lines.length === 0 ? (
{active.hint}
) : lines.length === 1 ? (
{lines[0]}
) : (
    {lines.map((l, j) =>
  • {l}
  • )}
)}
{activeIdx + 1} / {template.sections.length}
Slide content · One bullet per line {wordCount(text)}{active.target ? `/${active.target}` : ''} words
updateSection(active.id, v)} placeholder={active.hint} rows={6} /> {(active.checks || []).length > 0 && (
{active.checks.map(c => { const check = CHECKS[c]; if (!check) return null; const passed = check.test(text); return ( {passed ? : } {check.label} ); })}
)} {aiForSlide && (
AI suggestion · stub
{aiForSlide.msg}
)}
{InsertPanel}
); } // ---------- PAPER / DOCUMENT MODE — Notion single-surface page ---------- const paperLike = template.mode === 'paper'; // For paper-mode projects: optional LaTeX editor (CodeMirror + LaTeX.js preview). // Source stored as a single string at project.latexSource. const editorMode = (paperLike && project.editorMode) || 'rich'; const setEditorMode = (next) => { setStore(s => { const proj = s.projects[project.id]; // First time switching to LaTeX, seed the source from current sections. let nextLatex = proj.latexSource; if (next === 'latex' && (!nextLatex || nextLatex.trim() === '')) { nextLatex = template.sections.map(sec => `\\section{${sec.name}}\n${proj.sections[sec.id] || ''}\n` ).join('\n'); } return { ...s, projects: { ...s.projects, [project.id]: { ...proj, editorMode: next, latexSource: nextLatex }, }, }; }); }; const updateLatexSource = (src) => { setStore(s => ({ ...s, projects: { ...s.projects, [project.id]: { ...s.projects[project.id], latexSource: src } }, })); }; if (paperLike && editorMode === 'latex') { return ( <> {ProjectTabs} {Header} {HistoryPanel}
Editor
); } return ( <> {ProjectTabs} {Header} {HistoryPanel} {paperLike && (
Editor
)}
On this page
{template.sections.map(s => { const wc = wordCount(sections[s.id]); return ( ); })}

{project.name}

{totalWords} words · ~{readingMinutes(totalWords)} min read · {template.sections.length} sections{paperLike ? ' · long-form doc' : ''}
{template.sections.map(s => { const text = sections[s.id] || ''; const aiForSection = aiNotes ? aiNotes.find(n => n.sectionId === s.id) : null; return (

{s.name}

updateSection(s.id, v)} placeholder={`Start writing ${s.name.toLowerCase()}…`} hint={s.hint} serif={paperLike} /> {(s.checks || []).length > 0 && (
{s.checks.map(c => { const check = CHECKS[c]; if (!check) return null; const passed = check.test(text); return ( {passed ? : } {check.label} ); })} {s.target > 0 && ( = s.target * 0.7}> {wordCount(text)} / {s.target} words )}
)} {aiForSection && (
AI suggestion · stub
{aiForSection.msg}
)}
); })}
{InsertPanel}
); }; // ============================================================================ // Auto-save indicator. Drafts persist to localStorage on every keystroke, // so we show a transient "Saving…" pill when the project changes, settling // to "Saved · Xs ago" while idle. // ============================================================================ function SaveIndicator({ project }) { const [savedAt, setSavedAt] = useState(Date.now()); const [pulse, setPulse] = useState(false); const sectionsKey = JSON.stringify(project?.sections || {}); useEffect(() => { setPulse(true); const t1 = setTimeout(() => setPulse(false), 300); const t2 = setTimeout(() => setSavedAt(Date.now()), 350); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [sectionsKey]); // Tick the relative-time string const [, force] = useState(0); useEffect(() => { const i = setInterval(() => force(n => n + 1), 5000); return () => clearInterval(i); }, []); const ago = (() => { const d = Math.floor((Date.now() - savedAt) / 1000); if (d < 5) return 'just now'; if (d < 60) return `${d}s ago`; if (d < 3600) return `${Math.floor(d / 60)}m ago`; return `${Math.floor(d / 3600)}h ago`; })(); return ( {pulse ? 'Saving…' : `Saved · ${ago}`} ); } // ============================================================================ // RichBlock — Notion-style click-to-edit + Docs-style floating toolbar. // • Idle: shows rendered markdown (with KaTeX math) — looks like a real doc // • Click: switches to a textarea source view // • While editing: floating toolbar above with Bold / Italic / H2 / list / link / cite / math // • Slash commands still work via the underlying textarea // ============================================================================ const wrap = (text, l, r = l) => `${l}${text || 'text'}${r}`; const lineWrap = (text, prefix) => (text ? text.split('\n').map(line => line ? `${prefix}${line}` : line).join('\n') : `${prefix}`); const TOOLBAR = [ { id: 'bold', icon: 'pencil', label: `Bold (${MOD}+B)`, run: (sel) => wrap(sel, '**') }, { id: 'italic', icon: 'pencil', label: `Italic (${MOD}+I)`, run: (sel) => wrap(sel, '*') }, { id: 'code', icon: 'flask', label: 'Inline code', run: (sel) => wrap(sel, '`') }, { id: 'h2', icon: 'list', label: 'Heading', run: (sel) => `## ${sel || 'Heading'}` }, { id: 'h3', icon: 'list', label: 'Subheading', run: (sel) => `### ${sel || 'Subheading'}` }, { id: 'list', icon: 'list', label: 'Bullet list', run: (sel) => lineWrap(sel, '- ') }, { id: 'numbered', icon: 'list', label: 'Numbered list', run: (sel) => lineWrap(sel, '1. ') }, { id: 'quote', icon: 'cite', label: 'Block quote', run: (sel) => lineWrap(sel, '> ') }, { id: 'link', icon: 'link', label: 'Link', run: (sel) => `[${sel || 'link text'}](https://)` }, { id: 'cite', icon: 'book', label: 'Citation @key', run: (sel) => `(@${sel || 'key'})` }, { id: 'math', icon: 'flask', label: 'Inline math (LaTeX)', run: (sel) => `$${sel || 'x^2'}$` }, { id: 'math-block', icon: 'flask', label: 'Math block', run: (sel) => `\n$$\n${sel || 'E = mc^2'}\n$$\n` }, ]; function RichBlock({ value, onChange, placeholder, serif, hint }) { const [editing, setEditing] = useState(false); const [slash, setSlash] = useState(null); const taRef = useRef(null); const containerRef = useRef(null); // Auto-grow when editing useEffect(() => { if (!editing || !taRef.current) return; taRef.current.style.height = 'auto'; taRef.current.style.height = taRef.current.scrollHeight + 'px'; }, [value, editing]); // Click outside to leave edit mode useEffect(() => { if (!editing) return; const onDocClick = (e) => { if (containerRef.current && !containerRef.current.contains(e.target)) { setEditing(false); setSlash(null); } }; document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, [editing]); const applyToolbar = (cmd) => { const ta = taRef.current; if (!ta) return; const { selectionStart: s, selectionEnd: e } = ta; const before = value.slice(0, s); const sel = value.slice(s, e); const after = value.slice(e); const replacement = cmd.run(sel); const next = before + replacement + after; onChange(next); // Reposition cursor at end of inserted text setTimeout(() => { if (taRef.current) { const pos = before.length + replacement.length; taRef.current.setSelectionRange(pos, pos); taRef.current.focus(); } }, 0); }; const onTextChange = (e) => { const val = e.target.value; const cursor = e.target.selectionStart; onChange(val); const before = val.slice(0, cursor); const m = before.match(/(?:^|\n)(\/[\w-]*)$/); if (m) { const q = m[1].slice(1).toLowerCase(); const choices = SLASH_COMMANDS.filter(c => c.label.toLowerCase().includes(q) || c.id.includes(q)).slice(0, 8); setSlash({ start: cursor - m[1].length, query: q, choices, idx: 0 }); } else { setSlash(null); } }; const insertSlash = (cmd) => { if (!slash) return; const before = value.slice(0, slash.start); const after = value.slice(slash.start + 1 + slash.query.length); const inserted = cmd.insert(); onChange(before + inserted + after); setSlash(null); setTimeout(() => { if (taRef.current) { const pos = before.length + inserted.length; taRef.current.setSelectionRange(pos, pos); taRef.current.focus(); } }, 0); }; const onKeyDown = (e) => { // ⌘B / ⌘I keyboard shortcuts (Word/Docs convention) if ((e.metaKey || e.ctrlKey) && !e.shiftKey) { if (e.key.toLowerCase() === 'b') { e.preventDefault(); applyToolbar(TOOLBAR.find(t => t.id === 'bold')); return; } if (e.key.toLowerCase() === 'i') { e.preventDefault(); applyToolbar(TOOLBAR.find(t => t.id === 'italic')); return; } } if (slash && slash.choices.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.min(s.choices.length - 1, s.idx + 1) })); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.max(0, s.idx - 1) })); } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertSlash(slash.choices[slash.idx]); } else if (e.key === 'Escape') setSlash(null); } }; return (
{/* Floating toolbar — only when actively editing */} {editing && (
e.preventDefault() /* keep textarea focused */}> {TOOLBAR.map(t => ( ))}
)} {editing ? (