// Deliverables view — multi-project PhD deliverable center.
// Each "project" is a draft of a template (paper, slides, poster, CV, etc.).
// You can keep many projects in flight, switch between them, version-rollback,
// drag in citations from your Bibliography or arXiv, 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: 'Security Assessment Report',
desc: 'Executive summary → Scope → Findings → Risk rating → Remediation → Appendix',
icon: 'book',
mode: 'paper',
sections: [
{ id: 'abstract', name: 'Abstract', target: 250, hint: 'One paragraph: question, method, finding, implication.', checks: ['hasNumber', 'hasFinding'] },
{ id: 'intro', name: 'Introduction', target: 1000, hint: 'Frame the problem, state the gap, name your contribution.', checks: ['hasGap', 'hasCitation'] },
{ id: 'methods', name: 'Methods', target: 800, hint: 'Reproducibility-first: subjects, materials, procedure, analysis.', checks: ['hasCitation', 'hasNumber'] },
{ id: 'results', name: 'Results', target: 800, hint: 'Lead with the effect. Numbers + figure refs. No interpretation here.', checks: ['hasNumber', 'hasFigure'] },
{ id: 'discussion', name: 'Discussion', target: 1000, hint: 'What it means, what it doesn\'t, limits, future work.', checks: ['hasLimit', 'hasCitation'] },
{ id: 'refs', name: 'References', target: 0, hint: 'Bibliography list. Drop @keys here from the Bibliography widget.', checks: [] },
],
},
{
id: 'thesis-chapter',
name: 'Incident Report',
desc: 'Timeline → Impact → Root cause → Containment → Lessons learned',
icon: 'book',
mode: 'paper',
sections: [
{ id: 'overview', name: 'Overview', target: 200, hint: 'What this chapter does and why it\'s here.', checks: [] },
{ id: 'background', name: 'Background', target: 1500, hint: 'Lit review focused on this chapter\'s question.', checks: ['hasCitation'] },
{ id: 'methods', name: 'Methods', target: 1500, hint: 'Reproducibility-first.', checks: ['hasCitation', 'hasNumber'] },
{ id: 'results', name: 'Results', target: 2000, hint: 'Findings + figures.', checks: ['hasFigure', 'hasNumber'] },
{ id: 'discussion', name: 'Discussion', target: 1500, hint: 'How it fits the larger thesis.', checks: ['hasLimit'] },
],
},
{
id: 'nsf-grfp',
name: 'NSF GRFP',
desc: 'Personal Statement (3 pages) + Research Plan (2 pages).',
icon: 'award',
mode: 'document',
sections: [
{ id: 'personal', name: 'Personal Statement', target: 1500, hint: 'Background, experiences, broader impacts. Write as a story.', checks: ['hasBroaderImpacts'] },
{ id: 'research', name: 'Research Plan', target: 1000, hint: 'Question, hypothesis, approach, intellectual merit.', checks: ['hasHypothesis', 'hasMerit'] },
],
},
{
id: 'conference-abstract',
name: 'Conference Abstract',
desc: 'Single section, 250 words. Lead with the result.',
icon: 'send',
mode: 'document',
sections: [
{ id: 'abs', name: 'Abstract', target: 250, hint: 'One paragraph. Lead with finding, end with implication.', checks: ['hasFinding', 'hasNumber'] },
],
},
{
id: 'defense-slides',
name: 'Defense Slides',
desc: 'Title → Outline → Background → Question → Methods → Results → Discussion → Q&A',
icon: 'kanban',
mode: 'slides',
sections: [
{ id: 'title', name: 'Title slide', target: 30, hint: 'Title, your name, advisor, date.', checks: [] },
{ id: 'outline', name: 'Outline', target: 60, hint: '5–7 bullet points covering the talk arc.', checks: [] },
{ id: 'background', name: 'Background', target: 200, hint: 'Just enough context to follow the question.', checks: ['hasCitation'] },
{ id: 'question', name: 'Question', target: 80, hint: 'Single sentence, falsifiable.', checks: [] },
{ id: 'methods', name: 'Methods', target: 200, hint: 'High-level. Save details for backup slides.', checks: ['hasNumber'] },
{ id: 'results', name: 'Results', target: 300, hint: 'One slide per finding. Lead with the headline.', checks: ['hasFigure', 'hasNumber'] },
{ id: 'discussion', name: 'Discussion', target: 200, hint: 'Implications + limits + next steps.', checks: ['hasLimit'] },
{ id: 'qa', name: 'Anticipated Q&A', target: 300, hint: 'Hardest 5 questions and your answers.', checks: [] },
],
},
{
id: 'poster',
name: 'Conference Poster',
desc: '4-quadrant scientific poster: Intro · Methods · Results · Discussion.',
icon: 'layout',
mode: 'poster',
sections: [
{ id: 'title', name: 'Title & Authors', target: 30, hint: 'Title, your name, advisor, affiliation.', checks: [] },
{ id: 'intro', name: 'Introduction', target: 200, hint: 'Question, gap, why-care.', checks: ['hasGap'] },
{ id: 'methods', name: 'Methods', target: 200, hint: 'High-level: subjects, design, analysis.', checks: ['hasNumber'] },
{ id: 'results', name: 'Results', target: 250, hint: 'Headline finding + 1–2 figures.', checks: ['hasFigure', 'hasNumber'] },
{ id: 'discussion', name: 'Discussion', target: 200, hint: 'What it means + next steps.', checks: ['hasLimit'] },
{ id: 'refs', name: 'References / Acks', target: 80, hint: '5–10 citations + funding + contact.', checks: ['hasCitation'] },
],
},
{
id: 'cv',
name: 'Academic CV',
desc: 'Standard sections: Education · Pubs · Talks · Awards · Service · Skills.',
icon: 'user',
mode: 'document',
sections: [
{ id: 'header', name: 'Header', target: 40, hint: 'Name, position, affiliation, contact.', checks: [] },
{ id: 'education', name: 'Education', target: 100, hint: 'Most recent first. Degree · Year · Institution.', checks: [] },
{ id: 'publications', name: 'Publications', target: 300, hint: 'Drop @keys from Bibliography. Group by type if needed.', checks: ['hasCitation'] },
{ id: 'talks', name: 'Invited talks & posters', target: 150, hint: 'Title · Venue · Year.', checks: [] },
{ id: 'awards', name: 'Awards & funding', target: 100, hint: 'Most recent first. Amount + year if applicable.', checks: [] },
{ id: 'service', name: 'Service & teaching', target: 100, hint: 'Reviewing, mentorship, TA roles.', checks: [] },
{ id: 'skills', name: 'Skills', target: 60, hint: 'Methods, languages, software.', checks: [] },
],
},
{
id: 'cover-letter',
name: 'Cover Letter',
desc: 'For job applications, journal submissions, or postdoc inquiries.',
icon: 'send',
mode: 'document',
sections: [
{ id: 'header', name: 'Header', target: 40, hint: 'Date, recipient, salutation.', checks: [] },
{ id: 'opener', name: 'Opening paragraph', target: 100, hint: 'Why you\'re writing + the position/journal.', checks: [] },
{ id: 'body', name: 'Why me', target: 250, hint: 'Specific achievements that match the call. Numbers > adjectives.', checks: ['hasNumber'] },
{ id: 'fit', name: 'Why this place', target: 150, hint: 'What about this group / journal makes it the right fit.', checks: [] },
{ id: 'close', name: 'Close', target: 60, hint: 'Thanks + next step + signature.', checks: [] },
],
},
{
id: 'irb-protocol',
name: 'IRB Protocol',
desc: 'Standard sections for human-subjects research approval.',
icon: 'shield',
mode: 'document',
sections: [
{ id: 'overview', name: 'Study overview', target: 200, hint: 'One-paragraph summary of purpose and procedures.', checks: [] },
{ id: 'background', name: 'Background & significance', target: 400, hint: 'Why this study? What gap does it close?', checks: ['hasGap', 'hasCitation'] },
{ id: 'aims', name: 'Specific aims & hypotheses', target: 250, hint: '2–3 aims. Each falsifiable.', checks: ['hasHypothesis'] },
{ id: 'participants', name: 'Participants & recruitment', target: 300, hint: 'Inclusion/exclusion criteria, sample size, recruitment plan.', checks: ['hasNumber'] },
{ id: 'procedures', name: 'Procedures', target: 500, hint: 'Step-by-step what subjects experience. Time burden in minutes.', checks: ['hasNumber'] },
{ id: 'risks', name: 'Risks & mitigation', target: 200, hint: 'Anticipated risks (physical, psychological, privacy) + mitigations.', checks: ['hasLimit'] },
{ id: 'benefits', name: 'Benefits', target: 100, hint: 'Direct + societal benefits. Be honest about minimal direct benefits.', checks: [] },
{ id: 'consent', name: 'Consent process', target: 200, hint: 'Who consents, when, written or verbal, capacity considerations.', checks: [] },
{ id: 'data', name: 'Data handling & confidentiality', target: 200, hint: 'Storage, access, identifiers, retention period.', checks: [] },
],
},
{
id: 'meeting-prep',
name: 'Advisor Meeting Prep',
desc: 'Bring this to your 1:1 — 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: 'What you actually did, with numbers when possible.', checks: ['hasNumber'] },
{ id: 'blockers', name: 'Blockers', target: 150, hint: 'What you need from them to move forward.', checks: ['hasLimit'] },
{ id: 'decisions', name: 'Decisions needed', target: 200, hint: 'Frame as A/B options with your recommendation.', checks: [] },
{ id: 'questions', name: 'Questions', target: 150, hint: 'Open questions you genuinely want their take on.', checks: [] },
{ id: 'followup', name: 'Action items (post-meeting)', target: 100, hint: 'Fill in during/after. Owner + due date for each.', checks: [] },
],
},
{
id: 'dissertation-formatting',
name: 'Dissertation Formatting Checklist',
desc: 'Catch-everything pass before ProQuest submission.',
icon: 'shield',
mode: 'document',
sections: [
{ id: 'frontmatter', name: 'Front matter', target: 0, hint: 'Title page, copyright, abstract, dedication, acknowledgements, ToC, list of figures/tables.', checks: [] },
{ id: 'margins', name: 'Margins & spacing', target: 0, hint: 'Verify school requirements. Most: 1" margins, double-spaced body, single-spaced quotes/captions.', checks: ['hasNumber'] },
{ id: 'fonts', name: 'Fonts & typography', target: 0, hint: 'One body font (Times/Garamond/Cambria) at 12pt. Captions 10–11pt. Headings consistent.', checks: ['hasNumber'] },
{ id: 'pagenumbers', name: 'Page numbering', target: 0, hint: 'Roman for front matter, Arabic from Intro onward. Check section breaks.', checks: [] },
{ id: 'figures', name: 'Figures & tables', target: 0, hint: 'Captions below figures, above tables. Numbered. Cited in text before they appear.', checks: ['hasFigure'] },
{ id: 'citations', name: 'Citations & references', target: 0, hint: 'Consistent style throughout. Every cite has a reference; every reference is cited.', checks: ['hasCitation'] },
{ id: 'appendices', name: 'Appendices', target: 0, hint: 'Lettered (A, B, C). Each cited in the body at least once.', checks: [] },
{ id: 'proquest', name: 'ProQuest submission', target: 0, hint: 'PDF/A format, embedded fonts, no broken links, abstract under word limit.', checks: [] },
],
},
{
id: 'faculty-hunt',
name: 'Faculty / Advisor Hunt',
desc: 'For prospective PhDs or finding committee members — research the people.',
icon: 'user',
mode: 'document',
sections: [
{ id: 'criteria', name: 'What you\'re looking for', target: 150, hint: 'Research area, methodology, working style, mentorship reputation.', checks: [] },
{ id: 'shortlist', name: 'Shortlist (5–10 names)', target: 400, hint: 'For each: name, institution, 2–3 representative papers, why they fit.', checks: ['hasCitation'] },
{ id: 'pubs', name: 'Recent publications', target: 300, hint: 'What have they published in the last 2 years? Drop @keys from Bibliography.', checks: ['hasCitation'] },
{ id: 'students', name: 'Current/recent students', target: 200, hint: 'Lab size, time-to-defense, where students go after.', checks: ['hasNumber'] },
{ id: 'reachout', name: 'Outreach plan', target: 200, hint: 'When to email, what to send, who to mention.', checks: [] },
{ id: 'notes', name: 'Conversation notes', target: 0, hint: 'After meetings/emails — vibes, fit signals, red flags.', checks: [] },
],
},
{
id: 'research-statement',
name: 'Research Statement',
desc: 'For faculty applications: past work, current direction, future arc.',
icon: 'sparkles',
mode: 'document',
sections: [
{ id: 'overview', name: 'Overview', target: 200, hint: 'One paragraph: your research identity in 2–3 sentences.', checks: [] },
{ id: 'past', name: 'Past work', target: 600, hint: 'What you\'ve done. Lead with results, cite your own papers.', checks: ['hasCitation', 'hasFinding'] },
{ id: 'current', name: 'Current direction', target: 400, hint: 'What you\'re working on now and why it matters.', checks: ['hasGap'] },
{ id: 'future', name: 'Future research', target: 600, hint: '3–5 year arc. 1–2 funding-ready specific aims.', checks: ['hasHypothesis'] },
{ id: 'broader', name: 'Broader impacts', target: 200, hint: 'Outreach, mentorship, what your group will look like.', checks: ['hasBroaderImpacts'] },
],
},
];
// ============================================================================
// 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: () => '' },
{ 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
Security deliverables hub — policies, IR reports, audit evidence, and architecture briefs. 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'}
9 templates · paper, slides, poster, CV, cover letter, 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 = (
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 ? ' · academic paper' : ''}
{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 ? (
{slash && slash.choices.length > 0 && (
Insert block
{slash.choices.map((c, i) => (
))}
)}
) : (
setEditing(true)}
onFocus={() => setEditing(true)}
tabIndex={0}
>
{value ? (
{value}
) : (
{hint || placeholder || 'Click to edit'}
)}
)}
);
}
// ============================================================================
// SlashTextarea — auto-grows; `/` opens a block-insert popover.
// ============================================================================
function SlashTextarea({ value, onChange, placeholder, serif, notion, rows = 1 }) {
const ref = useRef(null);
const [slash, setSlash] = useState(null); // { start, query, choices, idx }
useEffect(() => {
if (!ref.current || !notion) return;
ref.current.style.height = 'auto';
ref.current.style.height = ref.current.scrollHeight + 'px';
}, [value, notion]);
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 insertCmd = (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 (ref.current) {
const pos = before.length + inserted.length;
ref.current.setSelectionRange(pos, pos);
ref.current.focus();
}
}, 0);
};
const onKeyDown = (e) => {
if (!slash || slash.choices.length === 0) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.min(s.choices.length - 1, s.idx + 1) })); }
if (e.key === 'ArrowUp') { e.preventDefault(); setSlash(s => ({ ...s, idx: Math.max(0, s.idx - 1) })); }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertCmd(slash.choices[slash.idx]); }
if (e.key === 'Escape') setSlash(null);
};
return (
{slash && slash.choices.length > 0 && (
Insert block
{slash.choices.map((c, i) => (
))}
)}
);
}
// ============================================================================
// Poster panel — single-section block in the poster layout
// ============================================================================
function PosterPanel({ section, sections, updateSection }) {
if (!section) return null;
const text = sections[section.id] || '';
return (
{section.name}
updateSection(section.id, v)}
placeholder={section.hint}
hint={section.hint}
/>
);
}
// ============================================================================
// arXiv search — public ATOM API, CORS-enabled.
// ============================================================================
function ArxivSearch({ onPick }) {
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const [results, setResults] = useState([]);
const search = async () => {
if (!q.trim()) return;
setBusy(true);
try {
const url = `https://export.arxiv.org/api/query?search_query=all:${encodeURIComponent(q)}&max_results=5`;
const res = await fetch(url);
const xml = await res.text();
const doc = new DOMParser().parseFromString(xml, 'text/xml');
const entries = Array.from(doc.getElementsByTagName('entry')).map(e => {
const id = e.getElementsByTagName('id')[0]?.textContent?.split('/').pop() || '';
const title = e.getElementsByTagName('title')[0]?.textContent?.trim() || '';
const authors = Array.from(e.getElementsByTagName('author')).map(a => a.getElementsByTagName('name')[0]?.textContent?.trim()).filter(Boolean);
const year = (e.getElementsByTagName('published')[0]?.textContent || '').slice(0, 4);
return { id, title, authors, year };
});
setResults(entries);
} catch (e) {
fireToast('arXiv search failed', 'danger');
} finally {
setBusy(false);
}
};
return (
Search arXiv
{results.length > 0 && (
{results.map(r => {
const a = r.authors[0] ? r.authors[0].split(' ').pop() : 'Unknown';
const snippet = ` (${a}, ${r.year}; arXiv:${r.id})`;
return (
);
})}
)}
);
}
export default DeliverablesView;