import React, { useState, useEffect, useMemo, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Icon from './CanvasIcon';
import { MOD } from './platform';
const fireToast = (msg, kind = 'success') =>
window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
const fireActivity = (source, msg) =>
window.dispatchEvent(new CustomEvent('canvas-activity', { detail: { source, msg } }));
// Shared empty-state block — every widget uses this so the look stays consistent.
function EmptyState({ icon = 'sparkles', title, hint, action }) {
return (
{title}
{hint &&
{hint}
}
{action &&
{action}
}
);
}
// Cross-widget drag-drop helpers — one mime type, JSON payload tagged by `kind`.
const X_MIME = 'application/x-canvas-item';
const setDragPayload = (e, kind, payload) => {
e.dataTransfer.setData(X_MIME, JSON.stringify({ kind, payload }));
e.dataTransfer.effectAllowed = 'copy';
};
const readDragPayload = (e) => {
try { return JSON.parse(e.dataTransfer.getData(X_MIME)); } catch { return null; }
};
// ===== Bibliography =====
export function BibliographyWidget({ state, setState, openModal }) {
const formats = ['APA', 'MLA', 'Chicago', 'BibTeX'];
const fmt = state.format || 'APA';
const [sortBy, setSortBy] = useState('year');
const [dropOver, setDropOver] = useState(false);
const onDrop = async (e) => {
e.preventDefault();
setDropOver(false);
const data = readDragPayload(e);
if (!data || data.kind !== 'paper') return;
const p = data.payload;
// If we have a DOI, hit CrossRef and build a real citation; else stub from title.
const titleStr = p.title || 'Untitled';
let entry = {
key: 'cite' + Date.now(),
authors: 'Unknown',
title: titleStr.replace(/^.*?— /, ''),
journal: '',
year: new Date().getFullYear(),
cited: 0,
doi: p.doi || '',
};
if (p.doi) {
try {
const cleaned = p.doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//, '');
const res = await fetch(`https://api.crossref.org/works/${encodeURIComponent(cleaned)}`);
if (res.ok) {
const json = await res.json();
const w = json.message;
entry.authors = (w.author || []).map(a => `${a.family || ''}, ${(a.given || '').charAt(0)}.`).join('; ') || entry.authors;
entry.title = (w.title && w.title[0]) || entry.title;
entry.journal = (w['container-title'] && w['container-title'][0]) || '';
entry.year = (w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || entry.year;
const firstAuthor = (entry.authors.split(',')[0] || 'cite').toLowerCase().replace(/[^a-z]/g, '');
entry.key = firstAuthor + entry.year;
}
} catch { /* fall through with stub */ }
}
setState({ ...state, entries: [...state.entries, entry] });
fireToast(`@${entry.key} added from Reading Queue`);
fireActivity('Bibliography', `Added @${entry.key} (from Reading Queue)`);
};
const setFmt = (f) => setState({ ...state, format: f });
const formatEntry = (e) => {
if (fmt === 'APA') return `${e.authors} (${e.year}). ${e.title}. ${e.journal}.`;
if (fmt === 'MLA') return `${e.authors.split(',')[0]}, et al. "${e.title}." ${e.journal}, ${e.year}.`;
if (fmt === 'Chicago') return `${e.authors}. "${e.title}." ${e.journal} (${e.year}).`;
return `@article{${e.key},\n author = {${e.authors}},\n title = {${e.title}},\n journal = {${e.journal}},\n year = {${e.year}}\n}`;
};
const sorted = useMemo(() => {
const arr = [...state.entries];
if (sortBy === 'year') arr.sort((a, b) => b.year - a.year);
if (sortBy === 'author') arr.sort((a, b) => a.authors.localeCompare(b.authors));
if (sortBy === 'cited') arr.sort((a, b) => b.cited - a.cited);
return arr;
}, [state.entries, sortBy]);
const copy = () => {
const out = sorted.map(formatEntry).map(s => s.replace(/<[^>]+>/g, '')).join('\n\n');
navigator.clipboard?.writeText(out);
fireToast(`${sorted.length} citations copied as ${fmt}`);
};
const remove = (key) => {
const e = state.entries.find(x => x.key === key);
setState({ ...state, entries: state.entries.filter(x => x.key !== key) });
fireToast(`Removed @${e.key}`);
};
const edit = (entry) => openModal('add-citation', {
initial: entry,
onAdd: (next) => setState({ ...state, entries: state.entries.map(e => e.key === entry.key ? next : e) }),
});
return (
{ if (e.dataTransfer.types.includes(X_MIME)) { e.preventDefault(); setDropOver(true); } }}
onDragLeave={() => setDropOver(false)}
onDrop={onDrop}
style={{ display: 'flex', flexDirection: 'column', gap: 10, position: 'relative', flex: 1 }}
className={dropOver ? 'canvas-drop-active' : ''}
>
{dropOver && (
Drop to add citation
)}
{formats.map(f => (
))}
{sorted.map(e => (
@{e.key}
cited {e.cited.toLocaleString()}x
))}
{sorted.length === 0 && (
)}
);
}
// ===== Kanban (with priority filter chips + due-date sort) =====
const PRI_RANK = { high: 0, med: 1, low: 2 };
export function KanbanWidget({ state, setState, openModal }) {
const [dragId, setDragId] = useState(null);
const [dragCol, setDragCol] = useState(null);
const [editId, setEditId] = useState(null);
const [priFilter, setPriFilter] = useState('all');
const [sortBy, setSortBy] = useState('manual');
const move = (id, toCol) => setState({ ...state, cards: state.cards.map(c => c.id === id ? { ...c, col: toCol } : c) });
const remove = (id) => setState({ ...state, cards: state.cards.filter(c => c.id !== id) });
const updateTitle = (id, title) => setState({ ...state, cards: state.cards.map(c => c.id === id ? { ...c, title } : c) });
const addCard = (col) => openModal('add-task', {
onAdd: (card) => setState({ ...state, cards: [...state.cards, { ...card, id: 'k' + Date.now(), col }] }),
});
const editCard = (card) => openModal('add-task', {
initial: card,
onAdd: (next) => setState({ ...state, cards: state.cards.map(c => c.id === card.id ? { ...c, ...next } : c) }),
});
const visibleCards = (state.cards || []).filter(c => priFilter === 'all' || c.priority === priFilter);
const sortCards = (cards) => {
if (sortBy === 'priority') return [...cards].sort((a, b) => (PRI_RANK[a.priority] ?? 9) - (PRI_RANK[b.priority] ?? 9));
if (sortBy === 'due') {
const parseDue = (m) => {
if (!m) return Infinity;
const d = new Date(m);
return isNaN(d) ? Infinity : d.getTime();
};
return [...cards].sort((a, b) => parseDue(a.meta) - parseDue(b.meta));
}
return cards;
};
return (
<>
Filter
{[['all', 'All'], ['high', 'High'], ['med', 'Med'], ['low', 'Low']].map(([v, l]) => (
))}
Sort
{state.cols.map(col => {
const cards = sortCards(visibleCards.filter(c => c.col === col.id));
return (
{ e.preventDefault(); setDragCol(col.id); }}
onDragLeave={() => setDragCol(null)}
onDrop={(e) => { e.preventDefault(); if (dragId) move(dragId, col.id); setDragCol(null); setDragId(null); }}>
{col.label}
{cards.length}
{cards.map(card => (
{ setDragId(card.id); e.dataTransfer.effectAllowed = 'move'; }}
onDragEnd={() => { setDragId(null); setDragCol(null); }}
onDoubleClick={() => editCard(card)}>
{editId === card.id ? (
{ updateTitle(card.id, e.target.value); setEditId(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') setEditId(null); }}
/>
) : (
setEditId(card.id)}>{card.title}
)}
{card.priority.toUpperCase()}
·
{card.meta}
))}
);
})}
>
);
}
// ===== Pomodoro =====
export function PomodoroWidget({ state, setState }) {
const [running, setRunning] = useState(false);
const [mode, setMode] = useState('focus');
const [secs, setSecs] = useState(state.focus * 60);
const [editing, setEditing] = useState(false);
const total = (mode === 'focus' ? state.focus : state.brk) * 60;
useEffect(() => {
if (!running) return;
const t = setInterval(() => {
setSecs(s => {
if (s <= 1) {
const next = mode === 'focus' ? 'break' : 'focus';
setMode(next);
if (mode === 'focus') {
setState({ ...state, sessionsToday: state.sessionsToday + 1 });
fireToast('Focus session complete — 5 min break');
}
return (next === 'focus' ? state.focus : state.brk) * 60;
}
return s - 1;
});
}, 1000);
return () => clearInterval(t);
}, [running, mode, state, setState]);
const mm = String(Math.floor(secs / 60)).padStart(2, '0');
const ss = String(secs % 60).padStart(2, '0');
const r = 56, c = 2 * Math.PI * r;
const dash = c * (1 - secs / total);
const reset = () => { setRunning(false); setMode('focus'); setSecs(state.focus * 60); };
return (
{mm}:{ss}
{mode === 'focus' ? 'focus' : 'break'}
{editing ? (
focus
{ const v = +e.target.value || 25; setState({ ...state, focus: v }); if (!running) setSecs(v*60); }}/>
break
setState({ ...state, brk: +e.target.value || 5 })}/>
min
) : (
today {state.sessionsToday}·
{state.focus}/{state.brk} min
)}
);
}
// ===== Writing tracker =====
// Inline writing pad with multi-chapter targets, live word count, and 28-day heatmap.
const todayKey = () => new Date().toISOString().slice(0, 10);
const countWords = (s) => (s || '').trim().split(/\s+/).filter(Boolean).length;
export function WritingWidget({ state, setState }) {
const chapters = state.chapters || [];
const activeId = state.activeChapterId || chapters[0]?.id;
const active = chapters.find(c => c.id === activeId) || chapters[0];
const dailyTotals = state.dailyTotals || {};
const today = todayKey();
const todayWords = dailyTotals[today] || 0;
const target = active?.target ?? state.target ?? 500;
const pct = target > 0 ? Math.min(100, Math.round((todayWords / target) * 100)) : 0;
// Streak: count consecutive days back from today with words > 0
const streak = (() => {
let s = 0;
const d = new Date();
while (true) {
const k = d.toISOString().slice(0, 10);
if ((dailyTotals[k] || 0) > 0) { s++; d.setDate(d.getDate() - 1); } else break;
if (s > 365) break;
}
return s;
})();
const updateChapter = (id, patch) => setState({
...state,
chapters: chapters.map(c => c.id === id ? { ...c, ...patch } : c),
});
const onDraftChange = (text) => {
const words = countWords(text);
updateChapter(active.id, { draft: text });
// Track per-day session word count keyed off the active chapter's last-saved baseline
const sessionBaseline = active.savedAt === today ? (active.savedWords || 0) : 0;
const delta = Math.max(0, words - sessionBaseline);
setState({
...state,
chapters: chapters.map(c => c.id === active.id ? { ...c, draft: text } : c),
dailyTotals: { ...dailyTotals, [today]: (dailyTotals[today] || 0) - (state._sessionDelta || 0) + delta },
_sessionDelta: delta,
});
};
const saveSession = () => {
const words = countWords(active.draft || '');
setState({
...state,
chapters: chapters.map(c => c.id === active.id ? { ...c, savedAt: today, savedWords: words } : c),
_sessionDelta: 0,
});
fireToast(`Saved · ${words} words in ${active.name}`);
fireActivity('Writing', `Saved ${words} words to "${active.name}"`);
};
const addChapter = () => {
const id = 'c-' + Date.now();
setState({
...state,
chapters: [...chapters, { id, name: 'New chapter', target: 500, draft: '' }],
activeChapterId: id,
});
};
const deleteChapter = (id) => {
if (chapters.length === 1) return fireToast('Need at least one chapter', 'danger');
const next = chapters.filter(c => c.id !== id);
setState({ ...state, chapters: next, activeChapterId: next[0].id });
};
// 28-day heatmap
const days = Array.from({ length: 28 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() - (27 - i));
return d.toISOString().slice(0, 10);
});
const allCounts = Object.values(dailyTotals);
const maxDay = Math.max(target, ...allCounts, 1);
const intensity = (n) => {
if (!n) return 0;
const ratio = n / maxDay;
if (ratio < 0.25) return 1;
if (ratio < 0.5) return 2;
if (ratio < 0.75) return 3;
return 4;
};
const draftWords = countWords(active?.draft || '');
const totalWritten = chapters.reduce((sum, c) => sum + countWords(c.draft || ''), 0);
const totalTarget = chapters.reduce((sum, c) => sum + (c.target || 0), 0);
return (
<>
{/* Chapter switcher */}
{chapters.map(c => (
))}
{/* Active chapter editing */}
{active && (
<>
updateChapter(active.id, { name: e.target.value })}
/>
target
updateChapter(active.id, { target: +e.target.value || 0 })}
/>
{chapters.length > 1 && (
)}