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 && ( )}