import React, { useState, useMemo, useRef } from 'react';
import Icon from './CanvasIcon';
import { WIDGET_CATALOG, CATEGORIES } from './canvasData';
const fireToast = (msg, kind = 'success') =>
window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
// ---------- Add citation (with DOI lookup via CrossRef) ----------
export function AddCitationModal({ data, onClose }) {
const init = data.initial || {};
const [authors, setA] = useState(init.authors || '');
const [title, setT] = useState(init.title || '');
const [journal, setJ] = useState(init.journal || '');
const [year, setY] = useState(init.year || new Date().getFullYear());
const [doi, setDoi] = useState(init.doi || '');
const [lookingUp, setLookingUp] = useState(false);
const [bibtexInput, setBibtexInput] = useState('');
const [showBibtex, setShowBibtex] = useState(false);
const valid = authors && title && journal;
const editing = !!init.key;
const lookupDoi = async () => {
const cleaned = doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//, '');
if (!cleaned) return;
setLookingUp(true);
try {
const res = await fetch(`https://api.crossref.org/works/${encodeURIComponent(cleaned)}`);
if (!res.ok) throw new Error('Not found');
const json = await res.json();
const w = json.message;
const authorList = (w.author || []).map(a => `${a.family || ''}, ${(a.given || '').charAt(0)}.`).join('; ');
setA(authorList || 'Unknown');
setT((w.title && w.title[0]) || 'Untitled');
setJ((w['container-title'] && w['container-title'][0]) || '');
setY((w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || new Date().getFullYear());
fireToast('DOI resolved · fields filled');
} catch (e) {
fireToast(`DOI lookup failed: ${e.message}`, 'danger');
} finally {
setLookingUp(false);
}
};
const importBibtex = () => {
// Minimal BibTeX parser: pull author/title/journal/year out of the first @entry block.
const get = (field) => {
const m = bibtexInput.match(new RegExp(`${field}\\s*=\\s*[{"]([^}"]+)`, 'i'));
return m ? m[1].trim() : '';
};
const a = get('author');
const t = get('title');
const j = get('journal') || get('booktitle');
const y = get('year');
if (!t) { fireToast('Could not parse BibTeX', 'danger'); return; }
setA(a); setT(t); setJ(j); setY(y || new Date().getFullYear());
setShowBibtex(false);
fireToast('BibTeX imported');
};
const submit = () => {
if (!valid) return;
const key = init.key || (authors.split(',')[0] || 'cite').toLowerCase().replace(/[^a-z]/g, '') + year;
data.onAdd({ key, authors, title, journal, year: +year, cited: init.cited || 0, doi: doi || init.doi });
fireToast(`${editing ? 'Updated' : 'Added'} @${key}`);
onClose();
};
return (
e.stopPropagation()}>
{editing ? 'Edit citation' : 'Add citation'}
{editing ? `@${init.key}` : 'Paste a DOI or BibTeX, or fill in manually.'}
{showBibtex && (
)}
setA(e.target.value)} placeholder="Smith, J., & Doe, A."/>
setT(e.target.value)} placeholder="A study of …"/>
);
}
// ---------- Add task ----------
export function AddTaskModal({ data, onClose }) {
const init = data.initial || {};
const [title, setT] = useState(init.title || '');
const [priority, setP] = useState(init.priority || 'med');
const [meta, setM] = useState(init.meta || '');
const editing = !!init.id;
const submit = () => {
if (!title) return;
data.onAdd({ title, priority, meta: meta || '—' });
onClose();
};
return (
e.stopPropagation()}>
{editing ? 'Edit task' : 'Add task'}
{editing ? '' : 'It\'ll be added to the column.'}
);
}
// ---------- Add deadline ----------
export function AddDeadlineModal({ data, onClose }) {
const init = data.initial || {};
const [title, setT] = useState(init.title || '');
const [date, setD] = useState(init.date || '');
const [tag, setG] = useState(init.tag || 'writing');
const editing = !!init.id;
const submit = () => {
if (!title || !date) return;
data.onAdd({ title, date, tag });
fireToast(`${editing ? 'Updated' : 'Added'} · ${title}`);
onClose();
};
return (
e.stopPropagation()}>
{editing ? 'Edit deadline' : 'Add deadline'}
Will appear sorted by urgency.
);
}
// ---------- Log words ----------
export function LogWordsModal({ data, onClose }) {
const [n, setN] = useState(data.today);
const submit = () => {
data.onLog(+n || 0);
fireToast(`Logged ${n} words today.`);
onClose();
};
return (
e.stopPropagation()}>
Log today's words
Total written today, including edits to existing chapters.
);
}
// ---------- Confirm remove ----------
export function ConfirmRemoveModal({ data, onClose }) {
return (
e.stopPropagation()}>
Remove "{data.label}"?
Widget state stays in your project — you can add it back from the palette.
);
}
// ---------- Reading paper (with CrossRef search/DOI lookup) ----------
export function ReadingPaperModal({ data, onClose }) {
const init = data.initial || {};
const [title, setT] = useState(init.title || '');
const [priority, setP] = useState(init.priority || 'med');
const [time, setTime] = useState(init.time || '1h');
const [doi, setDoi] = useState(init.doi || '');
const [searchQ, setSearchQ] = useState('');
const [searching, setSearching] = useState(false);
const [results, setResults] = useState([]);
const editing = !!init.title && data.initial;
const lookupByDoi = async () => {
const cleaned = doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//, '');
if (!cleaned) return;
setSearching(true);
try {
const res = await fetch(`https://api.crossref.org/works/${encodeURIComponent(cleaned)}`);
if (!res.ok) throw new Error('not found');
const json = await res.json();
const w = json.message;
const author = (w.author && w.author[0]) ? `${w.author[0].family}` : 'Unknown';
const yr = (w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || '';
setT(`${author} ${yr} — ${(w.title && w.title[0]) || 'Untitled'}`);
fireToast('DOI resolved');
} catch (e) {
fireToast(`Lookup failed: ${e.message}`, 'danger');
} finally {
setSearching(false);
}
};
const searchCrossref = async () => {
if (!searchQ.trim()) return;
setSearching(true);
try {
const res = await fetch(`https://api.crossref.org/works?query=${encodeURIComponent(searchQ)}&rows=5&select=DOI,title,author,issued`);
const json = await res.json();
setResults(json.message.items || []);
} catch (e) {
fireToast(`Search failed: ${e.message}`, 'danger');
} finally {
setSearching(false);
}
};
const pickResult = (w) => {
const author = (w.author && w.author[0]) ? w.author[0].family : 'Unknown';
const yr = (w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || '';
setT(`${author} ${yr} — ${(w.title && w.title[0]) || 'Untitled'}`);
setDoi(w.DOI || '');
setResults([]);
};
const submit = () => {
if (!title) return;
data.onAdd({ title, priority, time, doi });
onClose();
};
return (
e.stopPropagation()}>
{editing ? 'Edit paper' : 'Queue a paper'}
Search by title, paste a DOI, or type freely.
{results.length > 0 && (
{results.map((w, i) => (
))}
)}
setT(e.target.value)} placeholder="Author Year — Short title"/>
);
}
// ---------- Budget item ----------
export function BudgetItemModal({ data, onClose }) {
const init = data.initial || {};
const [label, setL] = useState(init.label || '');
const [spent, setS] = useState(init.spent ?? 0);
const [color, setC] = useState(init.color || '#3dd9d6');
const editing = !!init.label;
const colors = ['#3dd9d6', '#e864b8', '#f5b454', '#7ed98a', '#9b8cff', '#f06a6a'];
const submit = () => {
if (!label) return;
data.onSave({ label, spent: +spent || 0, color });
onClose();
};
return (
e.stopPropagation()}>
{editing ? 'Edit category' : 'Add category'}
{editing && data.onDelete && (
)}
);
}
// ---------- Note (with @mention autocomplete) ----------
export function NoteModal({ data, onClose }) {
const init = data.initial || {};
const [text, setT] = useState(init.text || '');
const [tag, setG] = useState(init.tag || '');
const [linkTo, setL] = useState(init.linkTo || '');
const [mention, setMention] = useState(null); // { start, query, choices }
const [mentionIdx, setMentionIdx] = useState(0);
const taRef = useRef(null);
const editing = !!init.id;
// Pull mention sources from the persisted canvas state so the modal stays self-contained.
const mentionSources = useMemo(() => {
try {
const all = JSON.parse(localStorage.getItem('canvas-states-v2') || '{}');
const out = [];
(all.bibliography?.entries || []).forEach(e => out.push({ key: '@' + e.key, label: e.title, kind: 'cite' }));
(all.writing?.chapters || []).forEach(c => out.push({ key: '@' + c.name.replace(/\s+/g, '-'), label: c.name, kind: 'chapter' }));
(all.kanban?.cards || []).forEach(c => out.push({ key: '@' + c.title.slice(0, 30).replace(/\s+/g, '-'), label: c.title, kind: 'task' }));
return out;
} catch { return []; }
}, []);
const onTextChange = (e) => {
const val = e.target.value;
const cursor = e.target.selectionStart;
setT(val);
// Look back from cursor for an @mention being typed
const before = val.slice(0, cursor);
const m = before.match(/@(\S*)$/);
if (m) {
const q = m[1].toLowerCase();
const choices = mentionSources
.filter(s => s.label.toLowerCase().includes(q) || s.key.toLowerCase().includes('@' + q))
.slice(0, 6);
setMention({ start: cursor - m[0].length, query: m[1], choices });
setMentionIdx(0);
} else {
setMention(null);
}
};
const insertMention = (item) => {
if (!mention) return;
const before = text.slice(0, mention.start);
const after = text.slice(mention.start + 1 + mention.query.length); // +1 for the @
const inserted = item.key + ' ';
setT(before + inserted + after);
setMention(null);
setTimeout(() => {
if (taRef.current) {
const pos = before.length + inserted.length;
taRef.current.setSelectionRange(pos, pos);
taRef.current.focus();
}
}, 0);
};
const onKeyDown = (e) => {
if (!mention || mention.choices.length === 0) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIdx(i => Math.min(mention.choices.length - 1, i + 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIdx(i => Math.max(0, i - 1)); }
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertMention(mention.choices[mentionIdx]); }
if (e.key === 'Escape') setMention(null);
};
const submit = () => {
if (!text.trim()) return;
data.onSave({ text: text.trim(), tag: tag.trim(), linkTo: linkTo.trim() });
onClose();
};
return (
e.stopPropagation()}>
{editing ? 'Edit note' : 'New note'}
Markdown supported · **bold**, *italic*, `code`, lists, links
{mention && mention.choices.length > 0 && (
{mention.choices.map((c, i) => (
))}
)}
{editing && data.onDelete && (
)}
);
}
// ---------- Habit ----------
export function HabitModal({ data, onClose }) {
const init = data.initial || {};
const [label, setL] = useState(init.label || '');
const [icon, setI] = useState(init.icon || 'flame');
const editing = !!init.id;
const iconOpts = ['flame', 'pencil', 'book', 'flask', 'heart', 'graph', 'star'];
const submit = () => {
if (!label) return;
data.onSave({ label, icon });
onClose();
};
return (
e.stopPropagation()}>
{editing ? 'Edit habit' : 'New habit'}
Tracked daily. Tap squares to mark done.
{editing && data.onDelete && (
)}
);
}
// ---------- Goal ----------
export function GoalModal({ data, onClose }) {
const init = data.initial || {};
const [label, setL] = useState(init.label || '');
const [progress, setP] = useState(init.progress ?? 0);
const [due, setD] = useState(init.due || '');
const editing = !!init.id;
const submit = () => {
if (!label) return;
data.onSave({ label, progress: +progress, due });
onClose();
};
return (
e.stopPropagation()}>
{editing ? 'Edit goal' : 'New goal'}
Quarterly OKR or dissertation milestone.
{editing && data.onDelete && (
)}
);
}
// ---------- Meeting ----------
export function MeetingModal({ data, onClose }) {
const init = data.initial || {};
const [who, setW] = useState(init.who || '');
const [date, setD] = useState(init.date || new Date().toISOString().slice(0, 10));
const [notes, setN] = useState(init.notes || '');
const [actions, setA] = useState(init.actions || '');
const editing = !!init.id;
const submit = () => {
if (!who) return;
data.onSave({ who, date, notes, actions });
onClose();
};
return (
e.stopPropagation()}>
{editing ? 'Edit meeting' : 'Log meeting'}
Advisor / collaborator / sponsor — anyone you want to track.
{editing && data.onDelete && (
)}
);
}
// ---------- Widget palette ----------
export function PaletteModal({ data, onClose }) {
const [q, setQ] = useState('');
const [cat, setCat] = useState('all');
const present = new Set(data.layout.map(w => w.type));
const filtered = useMemo(() => {
let r = WIDGET_CATALOG;
if (cat !== 'all') r = r.filter(w => w.cat === cat);
if (q) {
const ql = q.toLowerCase();
r = r.filter(w => w.name.toLowerCase().includes(ql) || w.desc.toLowerCase().includes(ql));
}
return r;
}, [q, cat]);
const add = (w) => {
if (present.has(w.type)) return;
data.onAdd(w);
fireToast(`${w.name} added to workspace`, w.critic ? 'critic' : 'success');
};
return (
e.stopPropagation()}>
Add widget
{WIDGET_CATALOG.length} widgets · {WIDGET_CATALOG.filter(w => w.critic).length} anti-yes-man
{filtered.map(w => {
const added = present.has(w.type);
return (
);
})}
{filtered.length === 0 && (
No widgets match "{q}".
)}
);
}
// ---------- Global content search (notes / quotes / citations / kanban / deadlines / outline) ----------
export function GlobalSearchModal({ data, onClose }) {
const [q, setQ] = useState('');
const [idx, setIdx] = useState(0);
const states = data.states || {};
const items = useMemo(() => {
if (!q.trim()) return [];
const ql = q.toLowerCase();
const out = [];
// Notes
(states.notes?.items || []).forEach(n => {
if ((n.text || '').toLowerCase().includes(ql) || (n.tag || '').toLowerCase().includes(ql) || (n.linkTo || '').toLowerCase().includes(ql)) {
out.push({ kind: 'Note', label: (n.text || '').slice(0, 80), sub: n.tag ? `#${n.tag}` : '', icon: 'notes', widgetType: 'notes' });
}
});
// Citations
(states.bibliography?.entries || []).forEach(e => {
const blob = `${e.title} ${e.authors} ${e.journal} ${e.key}`.toLowerCase();
if (blob.includes(ql)) out.push({ kind: 'Citation', label: e.title, sub: `${e.authors} (${e.year})`, icon: 'book', widgetType: 'bibliography' });
});
// Kanban
(states.kanban?.cards || []).forEach(c => {
if (`${c.title} ${c.meta || ''}`.toLowerCase().includes(ql)) {
out.push({ kind: 'Task', label: c.title, sub: `${c.priority?.toUpperCase()} · ${c.meta || ''}`, icon: 'kanban', widgetType: 'kanban' });
}
});
// Deadlines
(states.deadlines || []).forEach(d => {
if (`${d.title} ${d.tag}`.toLowerCase().includes(ql)) {
out.push({ kind: 'Deadline', label: d.title, sub: `${d.date} · ${d.tag}`, icon: 'calendar', widgetType: 'deadlines' });
}
});
// Highlights
(states.highlights?.items || []).forEach(h => {
if (`${h.text} ${h.citeKey}`.toLowerCase().includes(ql)) {
out.push({ kind: 'Quote', label: `"${h.text}"`.slice(0, 80), sub: h.citeKey ? `@${h.citeKey}` : '', icon: 'cite', widgetType: 'highlights' });
}
});
// Outline
(states.outline?.items || []).forEach(o => {
if ((o.text || '').toLowerCase().includes(ql)) {
out.push({ kind: 'Outline', label: o.text, sub: `depth ${o.depth}`, icon: 'list', widgetType: 'outline' });
}
});
// Documenter
(states.documenter?.entries || []).forEach(e => {
if ((e.text || '').toLowerCase().includes(ql)) {
out.push({ kind: 'Journal', label: e.text.slice(0, 80), sub: e.date, icon: 'pencil', widgetType: 'documenter' });
}
});
return out.slice(0, 30);
}, [q, states]);
const jumpTo = (it) => {
const el = document.querySelector(`[data-widget-id^="w-"][data-widget-type="${it.widgetType}"]`)
|| document.querySelector(`.widget`); // fallback: scroll to first
if (el) {
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)';
setTimeout(() => { el.style.boxShadow = ''; }, 1400);
}
onClose();
};
return (
e.stopPropagation()}>
{ setQ(e.target.value); setIdx(0); }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(items.length - 1, i + 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(0, i - 1)); }
if (e.key === 'Enter' && items[idx]) jumpTo(items[idx]);
}}
placeholder="Search across notes, citations, tasks, quotes, deadlines…"
style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: 'var(--canvas-text)', fontSize: 15, padding: '4px 0' }}
/>
esc
{!q.trim() && (
Type to search across your canvas content.
)}
{q.trim() && items.length === 0 && (
No matches for "{q}".
)}
{items.map((it, i) => (
))}
↑↓ navigate↵ jumpesc close
);
}
// ---------- Command palette ----------
export function CommandPaletteModal({ data, onClose }) {
const [q, setQ] = useState('');
const [idx, setIdx] = useState(0);
const items = useMemo(() => {
const all = [
...data.layout.map(w => {
const meta = WIDGET_CATALOG.find(m => m.type === w.type);
return { kind: 'widget', label: meta?.name || w.type, icon: meta?.icon || 'layout', sub: 'Open widget', action: () => {
const el = document.querySelector(`[data-widget-id="${w.id}"]`);
if (el) {
el.scrollIntoView({ block: 'center' });
el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)';
setTimeout(() => { el.style.boxShadow = ''; }, 1400);
}
}};
}),
...WIDGET_CATALOG.filter(w => !data.layout.find(l => l.type === w.type)).map(w => ({
kind: 'add', label: 'Add: ' + w.name, icon: w.icon, sub: w.desc, action: () => data.onAddWidget(w),
})),
{ kind: 'cmd', label: 'Switch to Insights', icon: 'insights', sub: 'View AI summaries', action: () => data.onSetView('insights') },
{ kind: 'cmd', label: 'Switch to Workspace', icon: 'layout', sub: 'View dashboard', action: () => data.onSetView('workspace') },
{ kind: 'cmd', label: 'Toggle theme', icon: 'star', sub: 'Dark / light', action: () => data.onToggleTheme() },
{ kind: 'cmd', label: 'Export workspace JSON', icon: 'download', sub: 'Download current layout', action: () => data.onExport() },
];
if (!q) return all.slice(0, 8);
const ql = q.toLowerCase();
return all.filter(it => it.label.toLowerCase().includes(ql) || it.sub.toLowerCase().includes(ql)).slice(0, 12);
}, [q, data]);
const run = (i) => { items[i]?.action(); onClose(); };
return (
e.stopPropagation()}>
{ setQ(e.target.value); setIdx(0); }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(items.length - 1, i + 1)); }
if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(0, i - 1)); }
if (e.key === 'Enter') run(idx);
}}
placeholder="Search widgets, switch view, run a command…"
style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: 'var(--canvas-text)', fontSize: 15, padding: '4px 0' }}
/>
esc
{items.map((it, i) => (
))}
{items.length === 0 && (
No matches.
)}
↑↓ navigate↵ runesc close
);
}