Spaces:
Running
Running
| 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 ( | |
| <div className="widget-empty"> | |
| <div className="widget-empty-icon"><Icon name={icon} size={20}/></div> | |
| <div className="widget-empty-title">{title}</div> | |
| {hint && <div className="widget-empty-hint">{hint}</div>} | |
| {action && <div className="widget-empty-action">{action}</div>} | |
| </div> | |
| ); | |
| } | |
| // 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}. <em>${e.journal}</em>.`; | |
| if (fmt === 'MLA') return `${e.authors.split(',')[0]}, et al. "${e.title}." <em>${e.journal}</em>, ${e.year}.`; | |
| if (fmt === 'Chicago') return `${e.authors}. "${e.title}." <em>${e.journal}</em> (${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 ( | |
| <div | |
| onDragOver={(e) => { 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 && ( | |
| <div className="canvas-drop-overlay"> | |
| <Icon name="plus" size={18}/> | |
| <span>Drop to add citation</span> | |
| </div> | |
| )} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}> | |
| <div className="format-tabs"> | |
| {formats.map(f => ( | |
| <button key={f} className={`format-tab ${fmt === f ? 'active' : ''}`} onClick={() => setFmt(f)}>{f}</button> | |
| ))} | |
| </div> | |
| <div style={{ display: 'flex', gap: 4 }}> | |
| <select className="select" style={{ width: 'auto', padding: '4px 8px', fontSize: 11, fontFamily: 'var(--canvas-mono)' }} value={sortBy} onChange={e => setSortBy(e.target.value)}> | |
| <option value="year">↓ year</option> | |
| <option value="author">A–Z</option> | |
| <option value="cited">↓ cited</option> | |
| </select> | |
| <button className="icon-btn" onClick={copy} title="Copy all"><Icon name="copy" size={14}/></button> | |
| <button className="icon-btn" onClick={() => openModal('add-citation', { onAdd: (entry) => setState({ ...state, entries: [...state.entries, entry] }) })} title="Add"><Icon name="plus" size={14}/></button> | |
| </div> | |
| </div> | |
| <div className="bib-list"> | |
| {sorted.map(e => ( | |
| <div key={e.key} className="note-row" style={{ padding: '8px 10px' }}> | |
| <div className="bib-cite" style={{ fontSize: 12 }} dangerouslySetInnerHTML={{ __html: formatEntry(e) }}/> | |
| <div className="bib-meta"> | |
| <span className="key">@{e.key}</span> | |
| <span>cited {e.cited.toLocaleString()}x</span> | |
| </div> | |
| <div className="row-actions"> | |
| <button className="icon-btn" onClick={() => edit(e)} title="Edit"><Icon name="pencil" size={11}/></button> | |
| <button className="icon-btn" onClick={() => remove(e.key)} title="Delete"><Icon name="trash" size={11}/></button> | |
| </div> | |
| </div> | |
| ))} | |
| {sorted.length === 0 && ( | |
| <EmptyState | |
| icon="book" | |
| title="No citations yet" | |
| hint="Add one with the + button, paste a DOI, or drag a paper here from the Reading Queue." | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ===== 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 ( | |
| <> | |
| <div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', fontSize: 11 }}> | |
| <span style={{ color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600 }}>Filter</span> | |
| {[['all', 'All'], ['high', 'High'], ['med', 'Med'], ['low', 'Low']].map(([v, l]) => ( | |
| <button key={v} className={`palette-cat ${priFilter === v ? 'active' : ''}`} | |
| onClick={() => setPriFilter(v)} style={{ padding: '3px 9px', fontSize: 11 }}>{l}</button> | |
| ))} | |
| <span style={{ marginLeft: 'auto', color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600 }}>Sort</span> | |
| <select className="select" style={{ width: 'auto', padding: '3px 8px', fontSize: 11 }} value={sortBy} onChange={e => setSortBy(e.target.value)}> | |
| <option value="manual">Manual</option> | |
| <option value="priority">Priority</option> | |
| <option value="due">Due date</option> | |
| </select> | |
| </div> | |
| <div className="kanban"> | |
| {state.cols.map(col => { | |
| const cards = sortCards(visibleCards.filter(c => c.col === col.id)); | |
| return ( | |
| <div key={col.id} | |
| className={`kan-col ${dragCol === col.id ? 'drag-target' : ''}`} | |
| onDragOver={(e) => { e.preventDefault(); setDragCol(col.id); }} | |
| onDragLeave={() => setDragCol(null)} | |
| onDrop={(e) => { e.preventDefault(); if (dragId) move(dragId, col.id); setDragCol(null); setDragId(null); }}> | |
| <div className="kan-col-head"> | |
| <span>{col.label}</span> | |
| <span className="count">{cards.length}</span> | |
| </div> | |
| {cards.map(card => ( | |
| <div key={card.id} | |
| className={`kan-card ${card.priority} ${dragId === card.id ? 'dragging' : ''}`} | |
| draggable={editId !== card.id} | |
| onDragStart={(e) => { setDragId(card.id); e.dataTransfer.effectAllowed = 'move'; }} | |
| onDragEnd={() => { setDragId(null); setDragCol(null); }} | |
| onDoubleClick={() => editCard(card)}> | |
| <span className="priority-bar"/> | |
| {editId === card.id ? ( | |
| <input | |
| className="inline-input" | |
| style={{ marginLeft: 6, width: 'calc(100% - 6px)' }} | |
| autoFocus | |
| defaultValue={card.title} | |
| onBlur={(e) => { updateTitle(card.id, e.target.value); setEditId(null); }} | |
| onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') setEditId(null); }} | |
| /> | |
| ) : ( | |
| <div className="kan-title" onClick={() => setEditId(card.id)}>{card.title}</div> | |
| )} | |
| <div className="kan-meta"> | |
| <span>{card.priority.toUpperCase()}</span> | |
| <span>·</span> | |
| <span>{card.meta}</span> | |
| <span style={{ flex: 1 }}/> | |
| <button className="icon-btn" style={{ width: 16, height: 16, opacity: 0.5 }} onClick={(e) => { e.stopPropagation(); remove(card.id); }} title="Delete"><Icon name="x" size={10}/></button> | |
| </div> | |
| </div> | |
| ))} | |
| <button className="add-tiny" onClick={() => addCard(col.id)}>+ Add</button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </> | |
| ); | |
| } | |
| // ===== 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 ( | |
| <div className="pomo"> | |
| <div className={`pomo-ring ${mode === 'break' ? 'break' : ''}`}> | |
| <svg width="130" height="130"> | |
| <circle className="track" cx="65" cy="65" r={r} strokeWidth="6" fill="none"/> | |
| <circle className="fill" cx="65" cy="65" r={r} strokeWidth="6" fill="none" strokeDasharray={c} strokeDashoffset={dash}/> | |
| </svg> | |
| <div className="pomo-time"> | |
| <div className="t">{mm}:{ss}</div> | |
| <div className="l">{mode === 'focus' ? 'focus' : 'break'}</div> | |
| </div> | |
| </div> | |
| <div className="pomo-controls"> | |
| <button className="btn btn-primary" onClick={() => setRunning(r => !r)}> | |
| <Icon name={running ? 'pause' : 'play'} size={13}/>{running ? 'Pause' : 'Start'} | |
| </button> | |
| <button className="btn" onClick={reset} title="Reset"><Icon name="reset" size={13}/></button> | |
| <button className="btn" onClick={() => setEditing(e => !e)} title="Edit durations"><Icon name="settings" size={13}/></button> | |
| </div> | |
| {editing ? ( | |
| <div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 11, color: 'var(--canvas-text-3)' }}> | |
| <span>focus</span> | |
| <input type="number" min="1" max="90" className="input" style={{ width: 50, padding: '4px 6px', fontFamily: 'var(--canvas-mono)' }} | |
| value={state.focus} onChange={e => { const v = +e.target.value || 25; setState({ ...state, focus: v }); if (!running) setSecs(v*60); }}/> | |
| <span>break</span> | |
| <input type="number" min="1" max="30" className="input" style={{ width: 50, padding: '4px 6px', fontFamily: 'var(--canvas-mono)' }} | |
| value={state.brk} onChange={e => setState({ ...state, brk: +e.target.value || 5 })}/> | |
| <span>min</span> | |
| </div> | |
| ) : ( | |
| <div className="pomo-stats"> | |
| <span>today <b>{state.sessionsToday}</b></span><span>·</span> | |
| <span>{state.focus}/{state.brk} min</span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ===== 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 */} | |
| <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}> | |
| {chapters.map(c => ( | |
| <button key={c.id} | |
| onClick={() => setState({ ...state, activeChapterId: c.id })} | |
| className={`format-tab ${c.id === active?.id ? 'active' : ''}`} | |
| style={{ padding: '4px 9px', fontSize: 11, fontFamily: 'var(--canvas-sans)', textTransform: 'none', letterSpacing: 0 }}> | |
| {c.name} | |
| </button> | |
| ))} | |
| <button className="add-tiny" onClick={addChapter} style={{ padding: '4px 8px' }}>+ Chapter</button> | |
| </div> | |
| {/* Active chapter editing */} | |
| {active && ( | |
| <> | |
| <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> | |
| <input | |
| className="inline-input" | |
| style={{ flex: 1, fontSize: 12.5, fontWeight: 500 }} | |
| value={active.name} | |
| onChange={e => updateChapter(active.id, { name: e.target.value })} | |
| /> | |
| <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 11, color: 'var(--canvas-text-3)' }}>target</span> | |
| <input | |
| type="number" | |
| className="inline-input" | |
| style={{ width: 70, fontFamily: 'var(--canvas-mono)' }} | |
| value={active.target ?? 500} | |
| onChange={e => updateChapter(active.id, { target: +e.target.value || 0 })} | |
| /> | |
| {chapters.length > 1 && ( | |
| <button className="icon-btn" style={{ width: 22, height: 22 }} onClick={() => deleteChapter(active.id)} title="Delete chapter"> | |
| <Icon name="trash" size={11}/> | |
| </button> | |
| )} | |
| </div> | |
| <textarea | |
| className="textarea" | |
| placeholder="Start writing here. Word count tracks live." | |
| style={{ minHeight: 110, fontFamily: 'var(--canvas-sans)', fontSize: 13, lineHeight: 1.55 }} | |
| value={active.draft || ''} | |
| onChange={e => onDraftChange(e.target.value)} | |
| /> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 10, fontFamily: 'var(--canvas-mono)', fontSize: 11, color: 'var(--canvas-text-3)' }}> | |
| <span style={{ color: 'var(--canvas-accent)', fontWeight: 600 }}>{draftWords}</span> | |
| <span>/</span> | |
| <span>{active.target ?? 500} words</span> | |
| <div className="progress" style={{ flex: 1, height: 4 }}> | |
| <i style={{ width: Math.min(100, (draftWords / Math.max(1, active.target ?? 500)) * 100) + '%' }}/> | |
| </div> | |
| <button className="btn" style={{ padding: '4px 9px', fontSize: 11 }} onClick={saveSession}> | |
| <Icon name="check" size={11}/>Save session | |
| </button> | |
| </div> | |
| </> | |
| )} | |
| {/* Today/streak/total stats */} | |
| <div className="write-stats"> | |
| <div className="stat"> | |
| <div className="stat-label">Today</div> | |
| <div className="stat-value accent">{todayWords}</div> | |
| <div className="stat-sub">{pct}% of {target}</div> | |
| </div> | |
| <div className="stat"> | |
| <div className="stat-label">Streak</div> | |
| <div className="stat-value">{streak}<span style={{ fontSize: 13, color: 'var(--canvas-text-3)', marginLeft: 3 }}>d</span></div> | |
| <div className="stat-sub">days writing</div> | |
| </div> | |
| <div className="stat"> | |
| <div className="stat-label">Total</div> | |
| <div className="stat-value">{totalWritten}</div> | |
| <div className="stat-sub">/ {totalTarget} all chapters</div> | |
| </div> | |
| </div> | |
| {/* 28-day heatmap */} | |
| <div> | |
| <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 4 }}> | |
| Last 28 days | |
| </div> | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(28, 1fr)', gap: 2 }}> | |
| {days.map(d => { | |
| const n = dailyTotals[d] || 0; | |
| const lvl = intensity(n); | |
| const bg = ['var(--canvas-surface-2)', 'var(--canvas-accent-glow)', 'rgba(99,102,241,0.45)', 'rgba(99,102,241,0.7)', 'var(--canvas-accent)'][lvl]; | |
| return ( | |
| <div key={d} | |
| title={`${d}: ${n} words`} | |
| style={{ aspectRatio: '1', borderRadius: 2, background: bg, border: '1px solid var(--canvas-border)' }}/> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </> | |
| ); | |
| } | |
| // ===== Deadlines (with .ics calendar export) ===== | |
| const toICSDate = (d) => { | |
| const z = (n) => String(n).padStart(2, '0'); | |
| return `${d.getUTCFullYear()}${z(d.getUTCMonth() + 1)}${z(d.getUTCDate())}T${z(d.getUTCHours())}${z(d.getUTCMinutes())}00Z`; | |
| }; | |
| const downloadICS = (deadline) => { | |
| const start = new Date(deadline.date + 'T09:00:00Z'); | |
| const end = new Date(deadline.date + 'T10:00:00Z'); | |
| const escape = (s) => (s || '').replace(/[,;\\]/g, m => '\\' + m).replace(/\n/g, '\\n'); | |
| const ics = [ | |
| 'BEGIN:VCALENDAR', | |
| 'VERSION:2.0', | |
| 'PRODID:-//Canvas//Deadlines//EN', | |
| 'BEGIN:VEVENT', | |
| `UID:${deadline.id}@canvas`, | |
| `DTSTAMP:${toICSDate(new Date())}`, | |
| `DTSTART:${toICSDate(start)}`, | |
| `DTEND:${toICSDate(end)}`, | |
| `SUMMARY:${escape(deadline.title)}`, | |
| `DESCRIPTION:Tag: ${escape(deadline.tag || '')}`, | |
| 'END:VEVENT', | |
| 'END:VCALENDAR', | |
| ].join('\r\n'); | |
| const blob = new Blob([ics], { type: 'text/calendar' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = `${deadline.title.replace(/[^\w\d-]/g, '_')}.ics`; | |
| a.click(); | |
| fireToast(`Calendar event downloaded for "${deadline.title}"`); | |
| }; | |
| export function DeadlinesWidget({ state, setState, openModal }) { | |
| const today = new Date(); | |
| today.setHours(0, 0, 0, 0); | |
| const enriched = state.map(d => { | |
| const date = new Date(d.date); | |
| const days = Math.ceil((date - today) / (1000 * 60 * 60 * 24)); | |
| return { ...d, days, date }; | |
| }).sort((a, b) => a.days - b.days); | |
| const remove = (id) => { | |
| const dl = state.find(x => x.id === id); | |
| setState(state.filter(x => x.id !== id)); | |
| fireToast(`Removed "${dl.title}"`); | |
| }; | |
| const edit = (dl) => openModal('add-deadline', { | |
| initial: dl, | |
| onAdd: (next) => setState(state.map(x => x.id === dl.id ? { ...x, ...next } : x)), | |
| }); | |
| return ( | |
| <> | |
| <div className="dl-list"> | |
| {enriched.map(d => { | |
| const cls = d.days <= 7 ? 'urgent' : d.days <= 21 ? 'warn' : ''; | |
| return ( | |
| <div key={d.id} className="dl-row" style={{ position: 'relative' }}> | |
| <div className={`dl-day ${cls}`}> | |
| <span className="num">{d.days}</span> | |
| <span className="lbl">days</span> | |
| </div> | |
| <div className="dl-info"> | |
| <div className="dl-title">{d.title}</div> | |
| <div className="dl-sub">{d.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} · {d.tag}</div> | |
| </div> | |
| <div className="row-actions"> | |
| <button className="icon-btn" onClick={() => downloadICS(d)} title="Add to calendar (.ics)"><Icon name="download" size={11}/></button> | |
| <button className="icon-btn" onClick={() => edit(d)} title="Edit"><Icon name="pencil" size={11}/></button> | |
| <button className="icon-btn" onClick={() => remove(d.id)} title="Delete"><Icon name="trash" size={11}/></button> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| {enriched.length === 0 && ( | |
| <EmptyState | |
| icon="calendar" | |
| title="No deadlines yet" | |
| hint="Track due dates and download them straight into your calendar as .ics files." | |
| /> | |
| )} | |
| </div> | |
| <button className="add-tiny" onClick={() => openModal('add-deadline', { | |
| onAdd: (dl) => setState([...state, { ...dl, id: 'd' + Date.now() }]), | |
| })}>+ Add deadline</button> | |
| </> | |
| ); | |
| } | |
| // ===== Budget ===== | |
| export function BudgetWidget({ state, setState, openModal }) { | |
| const total = state.items.reduce((a, b) => a + b.spent, 0); | |
| const pct = (total / state.cap) * 100; | |
| const [editingCap, setEditingCap] = useState(false); | |
| const editItem = (item, idx) => openModal('budget-item', { | |
| initial: item, | |
| onSave: (next) => setState({ ...state, items: state.items.map((x, i) => i === idx ? next : x) }), | |
| onDelete: () => setState({ ...state, items: state.items.filter((_, i) => i !== idx) }), | |
| }); | |
| const addItem = () => openModal('budget-item', { | |
| onSave: (next) => setState({ ...state, items: [...state.items, next] }), | |
| }); | |
| return ( | |
| <> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}> | |
| <div> | |
| <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em' }}>${total.toLocaleString()}</span> | |
| {editingCap ? ( | |
| <input type="number" autoFocus className="inline-input" style={{ width: 80, marginLeft: 6, fontFamily: 'var(--canvas-mono)' }} | |
| defaultValue={state.cap} | |
| onBlur={(e) => { setState({ ...state, cap: +e.target.value || state.cap }); setEditingCap(false); }} | |
| onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}/> | |
| ) : ( | |
| <span onClick={() => setEditingCap(true)} style={{ color: 'var(--canvas-text-3)', fontFamily: 'var(--canvas-mono)', fontSize: 12, marginLeft: 4, cursor: 'pointer', textDecoration: 'underline dashed', textDecorationColor: 'var(--canvas-text-4)', textUnderlineOffset: 2 }}>/ ${state.cap.toLocaleString()}</span> | |
| )} | |
| </div> | |
| <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 11, color: pct > 80 ? 'var(--canvas-warn)' : 'var(--canvas-text-3)' }}>{pct.toFixed(0)}% used</span> | |
| </div> | |
| <div className="budget-bar"> | |
| {state.items.map(item => ( | |
| <i key={item.label} style={{ background: item.color, width: (item.spent / state.cap * 100) + '%' }}/> | |
| ))} | |
| </div> | |
| <div className="budget-list"> | |
| {state.items.map((item, i) => ( | |
| <div key={item.label} className="budget-row" style={{ cursor: 'pointer' }} onClick={() => editItem(item, i)}> | |
| <span className="swatch" style={{ background: item.color }}/> | |
| <span>{item.label}</span> | |
| <span className="amt">${item.spent.toLocaleString()}</span> | |
| <span className="pct">{((item.spent/state.cap)*100).toFixed(0)}%</span> | |
| </div> | |
| ))} | |
| </div> | |
| <button className="add-tiny" onClick={addItem}>+ Add category</button> | |
| </> | |
| ); | |
| } | |
| // ===== Reading queue ===== | |
| export function ReadingQueueWidget({ state, setState, openModal }) { | |
| const colors = { high: 'var(--canvas-danger)', med: 'var(--canvas-warn)', low: 'var(--canvas-text-4)' }; | |
| const remove = (i, p) => { | |
| setState(state.filter((_, j) => j !== i)); | |
| fireToast(`Marked "${p.title.slice(0, 30)}…" as read`); | |
| }; | |
| const add = () => openModal('reading-paper', { onAdd: (p) => setState([...state, p]) }); | |
| const edit = (p, i) => openModal('reading-paper', { | |
| initial: p, | |
| onAdd: (next) => setState(state.map((x, j) => i === j ? next : x)), | |
| }); | |
| return ( | |
| <> | |
| <div className="dl-list"> | |
| {state.map((p, i) => ( | |
| <div key={i} className="dl-row" | |
| draggable | |
| onDragStart={(e) => setDragPayload(e, 'paper', p)} | |
| title="Drag to Bibliography to add as citation" | |
| style={{ padding: '7px 9px', position: 'relative', cursor: 'grab' }}> | |
| <span style={{ width: 4, height: 28, borderRadius: 2, background: colors[p.priority], flexShrink: 0 }}/> | |
| <div className="dl-info" onClick={() => edit(p, i)} style={{ cursor: 'pointer' }}> | |
| <div className="dl-title" style={{ fontSize: 12 }}>{p.title}</div> | |
| <div className="dl-sub">{p.priority} · ~{p.time}{p.doi ? ' · ' + p.doi : ''}</div> | |
| </div> | |
| <button className="icon-btn" style={{width:24,height:24}} title="Mark read" onClick={() => remove(i, p)}> | |
| <Icon name="check" size={13}/> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| <button className="add-tiny" onClick={add}>+ Add paper</button> | |
| </> | |
| ); | |
| } | |
| // ===== Notes / Scratchpad — Markdown rendering + full-text search ===== | |
| export function NotesWidget({ state, setState, openModal }) { | |
| const notes = state.items || []; | |
| const [search, setSearch] = useState(''); | |
| const add = () => openModal('note', { onSave: (n) => setState({ ...state, items: [{ ...n, id: 'n' + Date.now(), at: Date.now() }, ...notes] }) }); | |
| const edit = (note) => openModal('note', { | |
| initial: note, | |
| onSave: (next) => setState({ ...state, items: notes.map(x => x.id === note.id ? { ...x, ...next, at: Date.now() } : x) }), | |
| onDelete: () => setState({ ...state, items: notes.filter(x => x.id !== note.id) }), | |
| }); | |
| const fmt = (t) => { | |
| const d = Date.now() - t; | |
| if (d < 60_000) return 'just now'; | |
| if (d < 3600_000) return Math.floor(d / 60_000) + 'm'; | |
| if (d < 86400_000) return Math.floor(d / 3600_000) + 'h'; | |
| return Math.floor(d / 86400_000) + 'd'; | |
| }; | |
| const filtered = useMemo(() => { | |
| if (!search.trim()) return notes; | |
| const q = search.toLowerCase(); | |
| return notes.filter(n => | |
| (n.text || '').toLowerCase().includes(q) || | |
| (n.tag || '').toLowerCase().includes(q) || | |
| (n.linkTo || '').toLowerCase().includes(q) | |
| ); | |
| }, [notes, search]); | |
| return ( | |
| <> | |
| <div style={{ position: 'relative' }}> | |
| <span style={{ position: 'absolute', left: 9, top: '50%', transform: 'translateY(-50%)', color: 'var(--canvas-text-3)' }}> | |
| <Icon name="search" size={12}/> | |
| </span> | |
| <input | |
| className="input" | |
| placeholder="Search notes (text, tag, link)…" | |
| value={search} | |
| onChange={e => setSearch(e.target.value)} | |
| style={{ paddingLeft: 28, fontSize: 12 }} | |
| /> | |
| </div> | |
| <div className="note-list"> | |
| {filtered.map(n => ( | |
| <div key={n.id} className="note-row" onClick={() => edit(n)} style={{ cursor: 'pointer' }}> | |
| <div className="note-text canvas-md"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{n.text || ''}</ReactMarkdown> | |
| </div> | |
| <div className="note-meta"> | |
| {n.tag && <span className="tag-pill">{n.tag}</span>} | |
| <span>{fmt(n.at)}</span> | |
| {n.linkTo && <span>→ {n.linkTo}</span>} | |
| </div> | |
| </div> | |
| ))} | |
| {filtered.length === 0 && ( | |
| notes.length === 0 ? ( | |
| <EmptyState icon="notes" title="No notes yet" hint="Capture quick thoughts. Markdown supported. Type @ to mention citations, chapters, or tasks."/> | |
| ) : ( | |
| <EmptyState icon="search" title={`No notes match "${search}"`} hint="Try a shorter query or clear the search box."/> | |
| ) | |
| )} | |
| </div> | |
| <button className="add-tiny" onClick={add}>+ New note</button> | |
| </> | |
| ); | |
| } | |
| // ===== Habit tracker ===== | |
| export function HabitsWidget({ state, setState, openModal }) { | |
| const habits = state.items || []; | |
| const days = 7; | |
| const dayLabels = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; | |
| const toggle = (id, day) => { | |
| setState({ | |
| ...state, | |
| items: habits.map(h => h.id === id | |
| ? { ...h, log: { ...h.log, [day]: !h.log?.[day] } } | |
| : h), | |
| }); | |
| }; | |
| const add = () => openModal('habit', { onSave: (h) => setState({ ...state, items: [...habits, { ...h, id: 'h' + Date.now(), log: {} }] }) }); | |
| const edit = (h) => openModal('habit', { | |
| initial: h, | |
| onSave: (next) => setState({ ...state, items: habits.map(x => x.id === h.id ? { ...x, ...next } : x) }), | |
| onDelete: () => setState({ ...state, items: habits.filter(x => x.id !== h.id) }), | |
| }); | |
| return ( | |
| <> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> | |
| <div style={{ display: 'grid', gridTemplateColumns: '1fr repeat(7, 22px)', gap: 4, fontSize: 9.5, color: 'var(--canvas-text-4)', fontFamily: 'var(--canvas-mono)', textTransform: 'uppercase', letterSpacing: '0.06em', padding: '0 2px' }}> | |
| <span/> | |
| {dayLabels.map((d, i) => <span key={i} style={{ textAlign: 'center' }}>{d}</span>)} | |
| </div> | |
| {habits.map(h => { | |
| const checked = Array.from({length: days}, (_, i) => h.log?.[i]).filter(Boolean).length; | |
| return ( | |
| <div key={h.id} style={{ display: 'grid', gridTemplateColumns: '1fr repeat(7, 22px)', gap: 4, alignItems: 'center' }}> | |
| <div onClick={() => edit(h)} style={{ cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6 }}> | |
| <Icon name={h.icon || 'flame'} size={12} style={{ color: 'var(--canvas-accent)' }}/> | |
| <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{h.label}</span> | |
| <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 9.5, color: 'var(--canvas-text-3)' }}>{checked}/{days}</span> | |
| </div> | |
| {Array.from({length: days}).map((_, i) => ( | |
| <button key={i} | |
| onClick={() => toggle(h.id, i)} | |
| style={{ | |
| width: 20, height: 20, borderRadius: 4, | |
| background: h.log?.[i] ? 'var(--canvas-accent)' : 'var(--canvas-surface-2)', | |
| border: `1px solid ${h.log?.[i] ? 'var(--canvas-accent)' : 'var(--canvas-border)'}`, | |
| color: h.log?.[i] ? 'var(--canvas-bg)' : 'transparent', | |
| display: 'grid', placeItems: 'center', cursor: 'pointer', | |
| transition: 'all .12s', | |
| }}> | |
| {h.log?.[i] && <Icon name="check" size={10}/>} | |
| </button> | |
| ))} | |
| </div> | |
| ); | |
| })} | |
| {habits.length === 0 && ( | |
| <EmptyState icon="flame" title="No habits yet" hint="Track daily research practices. Read 1 paper, write 30 min, lab notebook entry."/> | |
| )} | |
| </div> | |
| <button className="add-tiny" onClick={add}>+ New habit</button> | |
| </> | |
| ); | |
| } | |
| // ===== Goals ===== | |
| export function GoalsWidget({ state, setState, openModal }) { | |
| const goals = state.items || []; | |
| const add = () => openModal('goal', { onSave: (g) => setState({ ...state, items: [...goals, { ...g, id: 'g' + Date.now() }] }) }); | |
| const edit = (g) => openModal('goal', { | |
| initial: g, | |
| onSave: (next) => setState({ ...state, items: goals.map(x => x.id === g.id ? { ...x, ...next } : x) }), | |
| onDelete: () => setState({ ...state, items: goals.filter(x => x.id !== g.id) }), | |
| }); | |
| const updateProgress = (id, pct) => setState({ ...state, items: goals.map(g => g.id === id ? { ...g, progress: pct } : g) }); | |
| return ( | |
| <> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> | |
| {goals.map(g => ( | |
| <div key={g.id} className="note-row" style={{ padding: '10px 12px', cursor: 'pointer' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8 }} onClick={() => edit(g)}> | |
| <Icon name="bullseye" size={13} style={{ color: 'var(--canvas-accent)' }}/> | |
| <span style={{ fontSize: 13, fontWeight: 500, flex: 1 }}>{g.label}</span> | |
| <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 11, color: 'var(--canvas-accent)' }}>{g.progress}%</span> | |
| </div> | |
| <div className="progress" style={{ height: 5, marginTop: 2 }}> | |
| <i style={{ width: g.progress + '%' }}/> | |
| </div> | |
| <input type="range" min="0" max="100" step="5" value={g.progress} | |
| onChange={e => updateProgress(g.id, +e.target.value)} | |
| style={{ width: '100%', accentColor: 'var(--canvas-accent)', marginTop: 2, height: 12 }}/> | |
| {g.due && <div className="note-meta"><span>due {g.due}</span></div>} | |
| </div> | |
| ))} | |
| {goals.length === 0 && ( | |
| <EmptyState icon="bullseye" title="No goals yet" hint="Quarterly OKRs, dissertation milestones, anything you want to track."/> | |
| )} | |
| </div> | |
| <button className="add-tiny" onClick={add}>+ New goal</button> | |
| </> | |
| ); | |
| } | |
| // ===== Meeting log ===== | |
| export function MeetingsWidget({ state, setState, openModal }) { | |
| const meetings = state.items || []; | |
| const add = () => openModal('meeting', { onSave: (m) => setState({ ...state, items: [{ ...m, id: 'm' + Date.now() }, ...meetings] }) }); | |
| const edit = (m) => openModal('meeting', { | |
| initial: m, | |
| onSave: (next) => setState({ ...state, items: meetings.map(x => x.id === m.id ? { ...x, ...next } : x) }), | |
| onDelete: () => setState({ ...state, items: meetings.filter(x => x.id !== m.id) }), | |
| }); | |
| return ( | |
| <> | |
| <div className="note-list"> | |
| {meetings.map(m => ( | |
| <div key={m.id} className="note-row" onClick={() => edit(m)} style={{ cursor: 'pointer' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> | |
| <Icon name="user" size={12} style={{ color: 'var(--canvas-accent)' }}/> | |
| <span style={{ fontSize: 12.5, fontWeight: 500, flex: 1 }}>{m.who}</span> | |
| <span className="note-meta"><span>{m.date}</span></span> | |
| </div> | |
| {m.notes && <div className="note-text" style={{ fontSize: 12, color: 'var(--canvas-text-2)' }}>{m.notes}</div>} | |
| {m.actions && <div className="note-meta"><span className="tag-pill">{(m.actions.match(/\n/g)?.length ?? 0) + 1} action(s)</span></div>} | |
| </div> | |
| ))} | |
| {meetings.length === 0 && ( | |
| <EmptyState icon="message" title="No meetings logged" hint="Capture decisions, action items, and last contact for each stakeholder."/> | |
| )} | |
| </div> | |
| <button className="add-tiny" onClick={add}>+ Log meeting</button> | |
| </> | |
| ); | |
| } | |
| // ===== Outline Builder — nested collapsible tree, drag-reorder, indent/outdent ===== | |
| export function OutlineWidget({ state, setState }) { | |
| const items = state.items || []; | |
| const expanded = state.expanded || {}; | |
| const [editingId, setEditingId] = useState(null); | |
| const toggle = (id) => setState({ ...state, expanded: { ...expanded, [id]: !expanded[id] } }); | |
| const update = (id, patch) => setState({ | |
| ...state, | |
| items: items.map(it => it.id === id ? { ...it, ...patch } : it), | |
| }); | |
| const addItem = (afterId, depth = 0) => { | |
| const id = 'o' + Date.now(); | |
| const idx = afterId ? items.findIndex(it => it.id === afterId) : items.length - 1; | |
| const next = [...items]; | |
| next.splice(idx + 1, 0, { id, text: '', depth }); | |
| setState({ ...state, items: next }); | |
| setTimeout(() => setEditingId(id), 0); | |
| }; | |
| const remove = (id) => setState({ ...state, items: items.filter(it => it.id !== id) }); | |
| const indent = (id) => { | |
| const idx = items.findIndex(it => it.id === id); | |
| if (idx <= 0) return; | |
| const max = items[idx - 1].depth + 1; | |
| if (items[idx].depth >= max) return; | |
| update(id, { depth: items[idx].depth + 1 }); | |
| }; | |
| const outdent = (id) => { | |
| const it = items.find(x => x.id === id); | |
| if (!it || it.depth === 0) return; | |
| update(id, { depth: it.depth - 1 }); | |
| }; | |
| return ( | |
| <> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> | |
| {items.length === 0 ? ( | |
| <EmptyState icon="list" title="Empty outline" hint="Click + Node below. Use Tab/Shift+Tab to indent, Enter for a sibling."/> | |
| ) : items.map((it, i) => { | |
| const hasChildren = items[i + 1] && items[i + 1].depth > it.depth; | |
| const isCollapsed = !!expanded[it.id] && hasChildren; | |
| // Hide rows that are descendants of a collapsed node | |
| let parentCollapsed = false; | |
| for (let j = i - 1; j >= 0; j--) { | |
| if (items[j].depth < it.depth) { | |
| if (expanded[items[j].id] && hasDescendantsAt(items, j, items[j].depth)) { parentCollapsed = true; break; } | |
| } | |
| if (items[j].depth === 0) break; | |
| } | |
| if (parentCollapsed) return null; | |
| return ( | |
| <div key={it.id} style={{ | |
| display: 'flex', alignItems: 'center', gap: 4, | |
| paddingLeft: it.depth * 18, | |
| fontSize: 13, lineHeight: 1.5, | |
| }}> | |
| <button className="icon-btn" style={{ width: 18, height: 18 }} | |
| onClick={() => hasChildren && toggle(it.id)} | |
| title={hasChildren ? (isCollapsed ? 'Expand' : 'Collapse') : ''}> | |
| {hasChildren ? ( | |
| <Icon name="chevron" size={11} style={{ transform: isCollapsed ? 'none' : 'rotate(90deg)', transition: 'transform .15s' }}/> | |
| ) : <span style={{ color: 'var(--canvas-text-4)', fontSize: 8 }}>•</span>} | |
| </button> | |
| {editingId === it.id ? ( | |
| <input | |
| className="inline-input" | |
| autoFocus | |
| defaultValue={it.text} | |
| onBlur={(e) => { update(it.id, { text: e.target.value }); setEditingId(null); }} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); addItem(it.id, it.depth); } | |
| if (e.key === 'Escape') setEditingId(null); | |
| if (e.key === 'Tab') { e.preventDefault(); update(it.id, { text: e.target.value }); e.shiftKey ? outdent(it.id) : indent(it.id); } | |
| }} | |
| /> | |
| ) : ( | |
| <span onClick={() => setEditingId(it.id)} style={{ flex: 1, cursor: 'text', color: it.text ? 'var(--canvas-text)' : 'var(--canvas-text-4)' }}> | |
| {it.text || 'Click to edit'} | |
| </span> | |
| )} | |
| <button className="icon-btn" style={{ width: 18, height: 18, opacity: 0.5 }} onClick={() => remove(it.id)}> | |
| <Icon name="x" size={10}/> | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div style={{ display: 'flex', gap: 4, fontSize: 11, color: 'var(--canvas-text-3)' }}> | |
| <button className="add-tiny" onClick={() => addItem(items[items.length - 1]?.id, 0)}>+ Node</button> | |
| <span style={{ color: 'var(--canvas-text-4)', fontFamily: 'var(--canvas-mono)' }}>· Tab/Shift+Tab to indent · Enter to add sibling</span> | |
| </div> | |
| </> | |
| ); | |
| } | |
| function hasDescendantsAt(items, idx, depth) { | |
| return items[idx + 1] && items[idx + 1].depth > depth; | |
| } | |
| // ===== Highlights & Quotes — paste a quote, attach citation, copy formatted ===== | |
| export function HighlightsWidget({ state, setState }) { | |
| const items = state.items || []; | |
| const [text, setText] = useState(''); | |
| const [citeKey, setCiteKey] = useState(''); | |
| const [page, setPage] = useState(''); | |
| const add = () => { | |
| if (!text.trim()) return; | |
| const id = 'q' + Date.now(); | |
| setState({ ...state, items: [{ id, text: text.trim(), citeKey: citeKey.trim(), page: page.trim(), at: Date.now() }, ...items] }); | |
| setText(''); setCiteKey(''); setPage(''); | |
| fireToast('Quote saved'); | |
| }; | |
| const copy = (q) => { | |
| const formatted = `"${q.text}" (${q.citeKey || 'unknown'}${q.page ? ', p. ' + q.page : ''})`; | |
| navigator.clipboard?.writeText(formatted); | |
| fireToast('Copied to clipboard'); | |
| }; | |
| const remove = (id) => setState({ ...state, items: items.filter(x => x.id !== id) }); | |
| return ( | |
| <> | |
| <div className="form-grid"> | |
| <textarea | |
| className="textarea" | |
| placeholder="Paste a quote or excerpt…" | |
| rows={3} | |
| style={{ minHeight: 60, fontSize: 12.5 }} | |
| value={text} | |
| onChange={e => setText(e.target.value)} | |
| /> | |
| <div style={{ display: 'flex', gap: 6 }}> | |
| <input className="input" placeholder="@citeKey" value={citeKey} onChange={e => setCiteKey(e.target.value)} style={{ flex: 1, fontFamily: 'var(--canvas-mono)' }}/> | |
| <input className="input" placeholder="page" value={page} onChange={e => setPage(e.target.value)} style={{ width: 60, fontFamily: 'var(--canvas-mono)' }}/> | |
| <button className="btn btn-primary" onClick={add} disabled={!text.trim()}> | |
| <Icon name="plus" size={13}/>Add | |
| </button> | |
| </div> | |
| </div> | |
| <div className="note-list"> | |
| {items.map(q => ( | |
| <div key={q.id} className="note-row"> | |
| <div className="note-text" style={{ fontStyle: 'italic' }}>"{q.text}"</div> | |
| <div className="note-meta"> | |
| {q.citeKey && <span className="tag-pill">@{q.citeKey}</span>} | |
| {q.page && <span>p. {q.page}</span>} | |
| <span style={{ flex: 1 }}/> | |
| <button className="icon-btn" style={{ width: 22, height: 22 }} onClick={() => copy(q)} title="Copy formatted"><Icon name="copy" size={11}/></button> | |
| <button className="icon-btn" style={{ width: 22, height: 22 }} onClick={() => remove(q.id)} title="Delete"><Icon name="trash" size={11}/></button> | |
| </div> | |
| </div> | |
| ))} | |
| {items.length === 0 && ( | |
| <EmptyState icon="cite" title="No quotes yet" hint="Paste a quote above with citation key and page; copy formatted with one click."/> | |
| )} | |
| </div> | |
| </> | |
| ); | |
| } | |
| // ===== LaTeX Scratchpad — live KaTeX rendering (loaded from CDN) ===== | |
| const KATEX_CDN_CSS = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css'; | |
| const KATEX_CDN_JS = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js'; | |
| let katexLoadingPromise = null; | |
| const ensureKatex = () => { | |
| if (window.katex) return Promise.resolve(window.katex); | |
| if (katexLoadingPromise) return katexLoadingPromise; | |
| katexLoadingPromise = new Promise((resolve, reject) => { | |
| const link = document.createElement('link'); | |
| link.rel = 'stylesheet'; | |
| link.href = KATEX_CDN_CSS; | |
| document.head.appendChild(link); | |
| const script = document.createElement('script'); | |
| script.src = KATEX_CDN_JS; | |
| script.onload = () => resolve(window.katex); | |
| script.onerror = () => reject(new Error('KaTeX CDN failed to load')); | |
| document.head.appendChild(script); | |
| }); | |
| return katexLoadingPromise; | |
| }; | |
| export function LatexWidget({ state, setState }) { | |
| const source = state.source ?? ''; | |
| const displayMode = state.displayMode ?? true; | |
| const containerRef = useRef(null); | |
| const [error, setError] = useState(''); | |
| const [katexReady, setKatexReady] = useState(!!window.katex); | |
| useEffect(() => { | |
| ensureKatex().then(() => setKatexReady(true)).catch(e => setError(e.message)); | |
| }, []); | |
| useEffect(() => { | |
| if (!katexReady || !containerRef.current) return; | |
| try { | |
| window.katex.render(source || '\\text{Type LaTeX above…}', containerRef.current, { | |
| displayMode, | |
| throwOnError: false, | |
| errorColor: '#DC2626', | |
| }); | |
| setError(''); | |
| } catch (e) { | |
| setError(e.message); | |
| } | |
| }, [source, displayMode, katexReady]); | |
| const insertSnippet = (snippet) => { | |
| setState({ ...state, source: (source || '') + snippet }); | |
| }; | |
| const copyOut = () => { | |
| navigator.clipboard?.writeText(source); | |
| fireToast('LaTeX copied'); | |
| }; | |
| return ( | |
| <> | |
| <textarea | |
| className="textarea" | |
| placeholder="\frac{1}{n}\sum_{i=1}^{n} x_i" | |
| style={{ minHeight: 70, fontFamily: 'var(--canvas-mono)', fontSize: 12.5 }} | |
| value={source} | |
| onChange={e => setState({ ...state, source: e.target.value })} | |
| /> | |
| <div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap', fontSize: 11 }}> | |
| <span style={{ color: 'var(--canvas-text-4)', fontFamily: 'var(--canvas-mono)' }}>insert:</span> | |
| {[ | |
| ['frac', '\\frac{a}{b}'], | |
| ['sum', '\\sum_{i=1}^{n}'], | |
| ['int', '\\int_{a}^{b}'], | |
| ['sqrt', '\\sqrt{x}'], | |
| ['vec', '\\vec{x}'], | |
| ['α', '\\alpha'], | |
| ['β', '\\beta'], | |
| ['Σ', '\\Sigma'], | |
| ].map(([label, snip]) => ( | |
| <button key={label} className="chip" onClick={() => insertSnippet(snip)}>{label}</button> | |
| ))} | |
| <span style={{ flex: 1 }}/> | |
| <button className="chip" onClick={() => setState({ ...state, displayMode: !displayMode })}> | |
| {displayMode ? 'inline' : 'display'} | |
| </button> | |
| <button className="icon-btn" style={{ width: 22, height: 22 }} onClick={copyOut} title="Copy LaTeX source"> | |
| <Icon name="copy" size={11}/> | |
| </button> | |
| </div> | |
| <div | |
| style={{ | |
| background: 'var(--canvas-bg-2)', | |
| border: '1px solid var(--canvas-border)', | |
| borderRadius: 7, | |
| padding: 14, | |
| minHeight: 60, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| color: 'var(--canvas-text)', | |
| overflowX: 'auto', | |
| }} | |
| > | |
| {!katexReady && !error && <span className="spinner"/>} | |
| {error && <span style={{ fontSize: 11, color: 'var(--canvas-danger)', fontFamily: 'var(--canvas-mono)' }}>{error}</span>} | |
| <div ref={containerRef} style={{ width: '100%', textAlign: 'center' }}/> | |
| </div> | |
| </> | |
| ); | |
| } | |
| // ===== Calendar — month grid with deadlines (red) and writing days (green) ===== | |
| export function CalendarWidget({ state, setState, allStates = {} }) { | |
| const monthStr = state.viewMonth || new Date().toISOString().slice(0, 7); | |
| const [year, month] = monthStr.split('-').map(Number); | |
| const first = new Date(year, month - 1, 1); | |
| const startWeekday = first.getDay(); // Sunday-indexed | |
| const daysInMonth = new Date(year, month, 0).getDate(); | |
| // Source data from cross-widget state | |
| const deadlines = (allStates.deadlines || []).reduce((m, d) => { | |
| const k = d.date.slice(0, 10); | |
| (m[k] ||= []).push({ kind: 'deadline', ...d }); | |
| return m; | |
| }, {}); | |
| const writing = ((allStates.writing && allStates.writing.dailyTotals) || {}); | |
| const kanbanCards = (allStates.kanban && allStates.kanban.cards) || []; | |
| const cells = []; | |
| for (let i = 0; i < startWeekday; i++) cells.push(null); | |
| for (let d = 1; d <= daysInMonth; d++) { | |
| const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`; | |
| cells.push({ d, dateStr }); | |
| } | |
| while (cells.length % 7) cells.push(null); | |
| const shiftMonth = (delta) => { | |
| const next = new Date(year, month - 1 + delta, 1); | |
| setState({ ...state, viewMonth: next.toISOString().slice(0, 7) }); | |
| }; | |
| const monthLabel = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); | |
| const today = new Date().toISOString().slice(0, 10); | |
| const goToToday = () => setState({ ...state, viewMonth: new Date().toISOString().slice(0, 7) }); | |
| const isCurrentMonth = monthStr === new Date().toISOString().slice(0, 7); | |
| return ( | |
| <> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> | |
| <button className="icon-btn" onClick={() => shiftMonth(-1)} title="Previous month"><Icon name="chevron" size={14} style={{ transform: 'rotate(180deg)' }}/></button> | |
| <div style={{ flex: 1, fontWeight: 600, fontSize: 13 }}>{monthLabel}</div> | |
| {!isCurrentMonth && ( | |
| <button className="chip" onClick={goToToday} title="Jump to today" style={{ fontSize: 10.5 }}>Today</button> | |
| )} | |
| <button className="icon-btn" onClick={() => shiftMonth(1)} title="Next month"><Icon name="chevron" size={14}/></button> | |
| </div> | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3, fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.06em', textAlign: 'center', fontFamily: 'var(--canvas-mono)' }}> | |
| {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => <div key={i}>{d}</div>)} | |
| </div> | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3 }}> | |
| {cells.map((c, i) => { | |
| if (!c) return <div key={i} style={{ aspectRatio: '1' }}/>; | |
| const dls = deadlines[c.dateStr] || []; | |
| const words = writing[c.dateStr] || 0; | |
| const dueCards = kanbanCards.filter(card => (card.meta || '').includes(c.dateStr)); | |
| const isToday = c.dateStr === today; | |
| return ( | |
| <div key={i} | |
| title={[ | |
| ...dls.map(d => `Deadline: ${d.title}`), | |
| ...(words ? [`${words} words written`] : []), | |
| ...dueCards.map(d => `Task: ${d.title}`), | |
| ].join('\n') || c.dateStr} | |
| style={{ | |
| aspectRatio: '1', | |
| border: `1px solid ${isToday ? 'var(--canvas-accent)' : 'var(--canvas-border)'}`, | |
| background: isToday ? 'var(--canvas-accent-glow)' : 'var(--canvas-bg-2)', | |
| borderRadius: 4, | |
| padding: 3, | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: 1, | |
| fontSize: 10, | |
| position: 'relative', | |
| }} | |
| > | |
| <span style={{ fontFamily: 'var(--canvas-mono)', color: isToday ? 'var(--canvas-accent)' : 'var(--canvas-text-2)', fontWeight: isToday ? 700 : 500 }}> | |
| {c.d} | |
| </span> | |
| <div style={{ display: 'flex', gap: 2, flexWrap: 'wrap', marginTop: 'auto' }}> | |
| {dls.length > 0 && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--canvas-danger)' }}/>} | |
| {words > 0 && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--canvas-ok)' }}/>} | |
| {dueCards.length > 0 && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--canvas-warn)' }}/>} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div style={{ display: 'flex', gap: 12, fontSize: 10, color: 'var(--canvas-text-3)', flexWrap: 'wrap' }}> | |
| <span><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--canvas-danger)', marginRight: 4 }}/>Deadlines</span> | |
| <span><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--canvas-ok)', marginRight: 4 }}/>Writing</span> | |
| <span><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--canvas-warn)', marginRight: 4 }}/>Tasks</span> | |
| </div> | |
| </> | |
| ); | |
| } | |
| // ===== Activity Feed — surfaces recent edits across all widgets ===== | |
| // Activity entries are written by other widgets via window.dispatchEvent('canvas-activity'). | |
| // We persist a rolling buffer of the last 100 events in this widget's own state. | |
| export function ActivityWidget({ state, setState }) { | |
| const events = state.events || []; | |
| useEffect(() => { | |
| const handler = (e) => { | |
| const entry = { id: 'a' + Date.now() + Math.random(), at: Date.now(), ...e.detail }; | |
| setState(prev => { | |
| const next = { ...(prev || {}), events: [entry, ...((prev && prev.events) || [])].slice(0, 100) }; | |
| return next; | |
| }); | |
| }; | |
| window.addEventListener('canvas-activity', handler); | |
| return () => window.removeEventListener('canvas-activity', handler); | |
| // setState ref is stable | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []); | |
| const fmt = (t) => { | |
| const d = Date.now() - t; | |
| if (d < 60_000) return 'just now'; | |
| if (d < 3600_000) return Math.floor(d / 60_000) + 'm ago'; | |
| if (d < 86400_000) return Math.floor(d / 3600_000) + 'h ago'; | |
| return Math.floor(d / 86400_000) + 'd ago'; | |
| }; | |
| return ( | |
| <> | |
| <div className="note-list"> | |
| {events.length === 0 && ( | |
| <EmptyState icon="graph" title="No activity yet" hint="Edits across your widgets will show up here as you work."/> | |
| )} | |
| {events.map(e => ( | |
| <div key={e.id} className="note-row" style={{ padding: '7px 9px' }}> | |
| <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> | |
| <span className="tag-pill">{e.source}</span> | |
| <span style={{ fontSize: 12, flex: 1 }}>{e.msg}</span> | |
| <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 10, color: 'var(--canvas-text-3)' }}>{fmt(e.at)}</span> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {events.length > 0 && ( | |
| <button className="add-tiny" onClick={() => setState({ ...state, events: [] })}>Clear feed</button> | |
| )} | |
| </> | |
| ); | |
| } | |
| // ===== Daily Documenter — date-stamped journal with stub AI weekly summary ===== | |
| export function DocumenterWidget({ state, setState }) { | |
| const entries = state.entries || []; | |
| const [draft, setDraft] = useState(''); | |
| const today = new Date().toISOString().slice(0, 10); | |
| const [generating, setGenerating] = useState(false); | |
| const append = () => { | |
| if (!draft.trim()) return; | |
| const entry = { id: 'doc' + Date.now(), date: today, text: draft.trim(), at: Date.now() }; | |
| setState({ ...state, entries: [entry, ...entries] }); | |
| setDraft(''); | |
| window.dispatchEvent(new CustomEvent('canvas-activity', { | |
| detail: { source: 'Documenter', msg: `Logged: ${draft.trim().slice(0, 50)}…` }, | |
| })); | |
| fireToast('Entry saved'); | |
| }; | |
| const remove = (id) => setState({ ...state, entries: entries.filter(e => e.id !== id) }); | |
| // TODO(LLM): wire to /api/summarize-week with last 7 days of entries. | |
| // Backend call shape (commented because endpoint isn't ready): | |
| // const generateSummary = async () => { | |
| // const last7 = entries.filter(e => Date.now() - e.at < 7*86400_000); | |
| // const res = await fetch(`${process.env.REACT_APP_API_URL}/api/canvas/summarize`, { | |
| // method: 'POST', | |
| // body: JSON.stringify({ entries: last7 }), | |
| // }); | |
| // const { summary } = await res.json(); | |
| // setState({ ...state, lastSummary: { at: Date.now(), text: summary } }); | |
| // }; | |
| const generateSummary = () => { | |
| // Stub: echo back a structured summary built from the local entries so the UI | |
| // shape is real even though no LLM is hooked up yet. | |
| setGenerating(true); | |
| setTimeout(() => { | |
| const last7 = entries.filter(e => Date.now() - e.at < 7 * 86400_000); | |
| const text = last7.length === 0 | |
| ? 'No entries in the last 7 days.' | |
| : `**This week** · ${last7.length} entries logged.\n\n` + | |
| '_LLM summary will replace this once the backend endpoint is wired._\n\n' + | |
| last7.slice(0, 3).map(e => `- ${e.date}: ${e.text.slice(0, 80)}${e.text.length > 80 ? '…' : ''}`).join('\n'); | |
| setState({ ...state, lastSummary: { at: Date.now(), text } }); | |
| setGenerating(false); | |
| fireToast('Weekly summary generated (stub)'); | |
| }, 600); | |
| }; | |
| // Group entries by date | |
| const grouped = entries.reduce((m, e) => { (m[e.date] ||= []).push(e); return m; }, {}); | |
| const dates = Object.keys(grouped).sort().reverse(); | |
| return ( | |
| <> | |
| <div className="form-grid"> | |
| <textarea | |
| className="textarea" | |
| placeholder={`What did you do today? (${today})`} | |
| rows={3} | |
| style={{ minHeight: 50, fontSize: 12.5 }} | |
| value={draft} | |
| onChange={e => setDraft(e.target.value)} | |
| onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) append(); }} | |
| /> | |
| <div style={{ display: 'flex', gap: 6 }}> | |
| <button className="btn btn-primary" onClick={append} disabled={!draft.trim()}> | |
| <Icon name="plus" size={13}/>Log entry | |
| </button> | |
| <button className="btn" onClick={generateSummary} disabled={entries.length === 0 || generating}> | |
| {generating ? <><div className="spinner"/>Summarizing</> : <><Icon name="sparkles" size={13}/>Weekly summary</>} | |
| </button> | |
| </div> | |
| </div> | |
| {state.lastSummary && ( | |
| <div className="review" style={{ borderLeftColor: 'var(--canvas-accent)' }}> | |
| <span className="review-tag" style={{ color: 'var(--canvas-accent)' }}> | |
| Generated {new Date(state.lastSummary.at).toLocaleString()} · stub | |
| </span> | |
| <div className="canvas-md"> | |
| <ReactMarkdown remarkPlugins={[remarkGfm]}>{state.lastSummary.text}</ReactMarkdown> | |
| </div> | |
| </div> | |
| )} | |
| <div className="note-list" style={{ maxHeight: 260 }}> | |
| {dates.map(date => ( | |
| <div key={date}> | |
| <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, fontFamily: 'var(--canvas-mono)', padding: '6px 2px 2px' }}> | |
| {new Date(date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} | |
| </div> | |
| {grouped[date].map(e => ( | |
| <div key={e.id} className="note-row" style={{ position: 'relative' }}> | |
| <div className="note-text">{e.text}</div> | |
| <div className="row-actions"> | |
| <button className="icon-btn" onClick={() => remove(e.id)}><Icon name="trash" size={11}/></button> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| ))} | |
| {entries.length === 0 && ( | |
| <EmptyState icon="pencil" title="No entries yet" hint={`Drop a line about today. Hit ${MOD}+↵ to log it. Tap Weekly summary anytime.`}/> | |
| )} | |
| </div> | |
| </> | |
| ); | |
| } | |
| // ===== PhD Journey — standard milestones with status + notes ===== | |
| // Captures the "General Journey" list: courses, prelim, lit review, IRB, | |
| // committee, topic, comps, defense, ProQuest. Each milestone has a status | |
| // (open/in-progress/completed) and an inline note. | |
| const PHD_MILESTONES = [ | |
| { id: 'courses', label: 'Course selection & dissertation credits', hint: 'Plan term-by-term. Check funding constraints.' }, | |
| { id: 'prelim', label: 'Preliminary exam', hint: 'Department format varies — consult your handbook.' }, | |
| { id: 'lit-review', label: 'Literature review', hint: 'Coverage + critique. Bibliography widget pairs well here.' }, | |
| { id: 'topic', label: 'Pick dissertation topic', hint: 'Narrow until your advisor pushes back.' }, | |
| { id: 'committee', label: 'Select committee', hint: 'Pros/cons of a co-chair: more buy-in, more scheduling.' }, | |
| { id: 'irb', label: 'IRB approval', hint: 'Allow 6–12 weeks. Pre-fill paperwork early.' }, | |
| { id: 'data', label: 'Data collection', hint: 'Pilot first. Plan for the inevitable instrument failure.' }, | |
| { id: 'comps', label: 'Comprehensive exam', hint: 'Department format varies.' }, | |
| { id: 'analysis', label: 'Data analysis & visualization', hint: 'Make the figures before the prose.' }, | |
| { id: 'writing', label: 'Write dissertation', hint: 'One chapter at a time. Aim for "good enough to defend".' }, | |
| { id: 'defense', label: 'Oral defense', hint: 'Slides + practice Q&A. Use the Defense Slides template.' }, | |
| { id: 'proquest', label: 'Final admin (ProQuest upload)', hint: 'Read the formatting checklist before you start formatting.' }, | |
| ]; | |
| export function PhdJourneyWidget({ state, setState }) { | |
| const statuses = state.statuses || {}; | |
| const notes = state.notes || {}; | |
| const [editingId, setEditingId] = useState(null); | |
| const completed = PHD_MILESTONES.filter(m => statuses[m.id] === 'completed').length; | |
| const total = PHD_MILESTONES.length; | |
| const pct = Math.round((completed / total) * 100); | |
| const cycleStatus = (id) => { | |
| const cur = statuses[id] || 'open'; | |
| const next = cur === 'open' ? 'in-progress' : cur === 'in-progress' ? 'completed' : 'open'; | |
| setState({ ...state, statuses: { ...statuses, [id]: next } }); | |
| }; | |
| const updateNote = (id, text) => setState({ ...state, notes: { ...notes, [id]: text } }); | |
| return ( | |
| <> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 12, color: 'var(--canvas-text-2)' }}> | |
| <span style={{ fontFamily: 'var(--canvas-mono)', fontWeight: 700, fontSize: 16, color: 'var(--canvas-text)' }}> | |
| {completed}<span style={{ color: 'var(--canvas-text-3)', fontWeight: 500 }}>/{total}</span> | |
| </span> | |
| <div className="progress" style={{ flex: 1, height: 6 }}> | |
| <i style={{ width: pct + '%' }}/> | |
| </div> | |
| <span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 11, color: 'var(--canvas-text-3)' }}>{pct}%</span> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> | |
| {PHD_MILESTONES.map(m => { | |
| const status = statuses[m.id] || 'open'; | |
| const note = notes[m.id] || ''; | |
| return ( | |
| <div key={m.id} className={`phd-milestone milestone-${status}`}> | |
| <button | |
| className="phd-milestone-check" | |
| onClick={() => cycleStatus(m.id)} | |
| title={`Status: ${status} (click to advance)`} | |
| > | |
| {status === 'completed' && <Icon name="check" size={11}/>} | |
| {status === 'in-progress' && <span className="task-check-dot"/>} | |
| </button> | |
| <div className="phd-milestone-body" onClick={() => setEditingId(editingId === m.id ? null : m.id)}> | |
| <div className="phd-milestone-label">{m.label}</div> | |
| {(note || editingId === m.id) ? ( | |
| editingId === m.id ? ( | |
| <input | |
| className="inline-input" | |
| autoFocus | |
| defaultValue={note} | |
| placeholder={m.hint} | |
| onBlur={(e) => { updateNote(m.id, e.target.value); setEditingId(null); }} | |
| onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') setEditingId(null); }} | |
| onClick={(e) => e.stopPropagation()} | |
| style={{ marginTop: 2, fontSize: 11.5 }} | |
| /> | |
| ) : ( | |
| <div className="phd-milestone-note">{note}</div> | |
| ) | |
| ) : ( | |
| <div className="phd-milestone-hint">{m.hint}</div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </> | |
| ); | |
| } | |
| // ===== PhD Resources — curated links + open-source apps ===== | |
| // Static curated list of useful PhD tools and resources, plus user-added links. | |
| const PHD_RESOURCE_GROUPS = [ | |
| { | |
| label: 'Open-source PhD tools', | |
| items: [ | |
| { name: 'Zotero', href: 'https://www.zotero.org/', desc: 'Reference manager — free and open source' }, | |
| { name: 'Obsidian', href: 'https://obsidian.md/', desc: 'Local-first knowledge graph for notes' }, | |
| { name: 'JabRef', href: 'https://www.jabref.org/', desc: 'BibTeX-native reference manager' }, | |
| { name: 'Pandoc', href: 'https://pandoc.org/', desc: 'Universal document converter' }, | |
| { name: 'Quarto', href: 'https://quarto.org/', desc: 'Scientific publishing with R/Python/Julia' }, | |
| ], | |
| }, | |
| { | |
| label: 'Writing & formatting', | |
| items: [ | |
| { name: 'Overleaf', href: 'https://www.overleaf.com/', desc: 'Browser LaTeX editor with templates' }, | |
| { name: 'LaTeX Templates', href: 'https://www.latextemplates.com/', desc: 'Thesis, CV, poster templates' }, | |
| { name: 'Hemingway Editor', href: 'https://hemingwayapp.com/', desc: 'Plain-language readability check' }, | |
| ], | |
| }, | |
| { | |
| label: 'Community & career', | |
| items: [ | |
| { name: 'Academic Twitter / #PhDChat', href: 'https://twitter.com/search?q=%23PhDChat', desc: 'Peers + advisors discussing the grind' }, | |
| { name: 'ORCID', href: 'https://orcid.org/', desc: 'Permanent researcher ID for citations + grants' }, | |
| { name: 'Conferences & CFPs (WikiCFP)', href: 'http://www.wikicfp.com/', desc: 'Upcoming deadlines across fields' }, | |
| ], | |
| }, | |
| ]; | |
| export function PhdResourcesWidget({ state, setState }) { | |
| const customLinks = state.customLinks || []; | |
| const [name, setName] = useState(''); | |
| const [href, setHref] = useState(''); | |
| const addLink = () => { | |
| if (!name.trim() || !href.trim()) return; | |
| setState({ ...state, customLinks: [...customLinks, { id: 'r' + Date.now(), name: name.trim(), href: href.trim() }] }); | |
| setName(''); setHref(''); | |
| fireToast('Resource added'); | |
| }; | |
| const removeLink = (id) => setState({ ...state, customLinks: customLinks.filter(l => l.id !== id) }); | |
| return ( | |
| <> | |
| {PHD_RESOURCE_GROUPS.map(g => ( | |
| <div key={g.label}> | |
| <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 6 }}> | |
| {g.label} | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 10 }}> | |
| {g.items.map(it => ( | |
| <a key={it.name} href={it.href} target="_blank" rel="noopener noreferrer" className="phd-resource-link"> | |
| <span className="phd-resource-name">{it.name}</span> | |
| <span className="phd-resource-desc">{it.desc}</span> | |
| </a> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| {customLinks.length > 0 && ( | |
| <div> | |
| <div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 6 }}> | |
| Your links | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 10 }}> | |
| {customLinks.map(l => ( | |
| <div key={l.id} className="phd-resource-link" style={{ position: 'relative' }}> | |
| <a href={l.href} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', flexDirection: 'column', flex: 1, textDecoration: 'none', color: 'inherit' }}> | |
| <span className="phd-resource-name">{l.name}</span> | |
| <span className="phd-resource-desc">{l.href}</span> | |
| </a> | |
| <button className="icon-btn" onClick={() => removeLink(l.id)} style={{ position: 'absolute', right: 4, top: 4, width: 20, height: 20 }}> | |
| <Icon name="x" size={11}/> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <div style={{ display: 'flex', gap: 4 }}> | |
| <input className="input" placeholder="Resource name" value={name} onChange={e => setName(e.target.value)} style={{ fontSize: 12, padding: '5px 8px' }}/> | |
| <input className="input" placeholder="https://…" value={href} onChange={e => setHref(e.target.value)} style={{ fontSize: 12, padding: '5px 8px', flex: 2 }}/> | |
| <button className="btn" onClick={addLink} disabled={!name.trim() || !href.trim()}> | |
| <Icon name="plus" size={12}/>Add | |
| </button> | |
| </div> | |
| </> | |
| ); | |
| } | |
| // ===== Stub — roadmap preview card ===== | |
| // Shows what's coming for this widget type so adding it from the palette | |
| // doesn't feel like a dead end. | |
| const STUB_PLANS = { | |
| 'concept-map': ['Drag papers as nodes', 'Connect by theme', 'Auto-cluster by citation overlap'], | |
| 'highlights': ['Pull quotes with auto-citation', 'Search across all notes'], | |
| 'paper-tldr': ['Drop a PDF', 'Get claim / method / limits / gaps', 'Save to Bibliography in one click'], | |
| 'outline': ['Collapsible tree', 'Drop Insights into slots', 'Promote to Deliverable section'], | |
| 'latex': ['Render math as you type', 'Snippet library', 'Copy as image / TeX'], | |
| 'draft-locker': ['Versioned chapter drafts', 'Diff between versions', 'Roll back any change'], | |
| 'gantt': ['Proposal → IRB → defense timeline', 'Critical-path highlighting', 'Drag to reschedule'], | |
| 'mood': ['Daily slider', 'Trend graph', 'Correlate with productive days'], | |
| 'sleep': ['Sleep duration vs. word output', 'Energy heatmap', 'Apple Health import'], | |
| 'focus': ['Curated ambient playlists', 'Focus session timer', 'Auto-pause on Pomodoro break'], | |
| 'cfp': ['CFP deadlines by venue', 'Fit score by topic', 'Submission status pipeline'], | |
| 'grants': ['Grant deadlines + amounts', 'Award log', 'Generate budget justification'], | |
| 'crm': ['Collaborators with last touch', 'Quick-add from Meeting Log', 'Reminders for cold contacts'], | |
| 'cv': ['Track outputs over time', 'Auto-generate CV from Bibliography', 'Highlight by impact factor'], | |
| 'datasets': ['Public datasets by domain', 'Saved searches', 'License + access notes'], | |
| 'methods': ['When to use what test', 'Examples + citations', 'Saved templates per chapter'], | |
| 'discounts': ['Software & services with edu pricing', 'Discount expiration tracking'], | |
| 'assumption': ['Names hidden assumptions', 'Asks "what if wrong?"', 'Logs to a hypothesis tree'], | |
| 'whats-missing': ['Gap analysis on lit review', 'Compares to top venues', 'Suggests targeted reads'], | |
| 'calibrator': ['Challenges every "results show" claim', 'Asks for the prior', 'Flags p-hacking patterns'], | |
| }; | |
| export function StubWidget({ meta }) { | |
| const plan = STUB_PLANS[meta.type] || []; | |
| return ( | |
| <div className="widget-stub"> | |
| <div className="widget-stub-icon"><Icon name={meta.icon} size={18}/></div> | |
| <div className="widget-stub-tag">Coming soon</div> | |
| <div className="widget-stub-title">{meta.name}</div> | |
| <div className="widget-stub-desc">{meta.desc}</div> | |
| {plan.length > 0 && ( | |
| <ul className="widget-stub-plan"> | |
| {plan.map((p, i) => <li key={i}>{p}</li>)} | |
| </ul> | |
| )} | |
| </div> | |
| ); | |
| } | |