Spaces:
Sleeping
Sleeping
Neon:ryan commited on
Commit ·
03d90a3
1
Parent(s): e6ba1a2
Draft 1
Browse files- phd-advisor-frontend/src/components/AppHeader.js +8 -3
- phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js +540 -0
- phd-advisor-frontend/src/components/canvas/CanvasModals.js +213 -4
- phd-advisor-frontend/src/components/canvas/CanvasWidgets.js +327 -5
- phd-advisor-frontend/src/components/canvas/canvasData.js +68 -1
- phd-advisor-frontend/src/pages/CanvasPage.js +104 -23
- phd-advisor-frontend/src/styles/CanvasPage.css +701 -27
phd-advisor-frontend/src/components/AppHeader.js
CHANGED
|
@@ -28,9 +28,13 @@ const AppHeader = ({
|
|
| 28 |
if (onNavigateToCanvas) onNavigateToCanvas(view);
|
| 29 |
};
|
| 30 |
|
|
|
|
|
|
|
| 31 |
const isOnHome = currentPage === 'home';
|
| 32 |
const isOnChat = currentPage === 'chat';
|
| 33 |
-
const isOnCanvas = currentPage === 'canvas';
|
|
|
|
|
|
|
| 34 |
|
| 35 |
return (
|
| 36 |
<header className="floating-header app-header">
|
|
@@ -62,8 +66,9 @@ const AppHeader = ({
|
|
| 62 |
|
| 63 |
<div className="canvas-tabs chat-view-tabs">
|
| 64 |
<button className={`tab ${isOnChat ? 'active' : ''}`} onClick={onNavigateToChat}>Chat</button>
|
| 65 |
-
<button className={`tab ${
|
| 66 |
-
<button className={`tab ${
|
|
|
|
| 67 |
</div>
|
| 68 |
|
| 69 |
<div className="header-right">
|
|
|
|
| 28 |
if (onNavigateToCanvas) onNavigateToCanvas(view);
|
| 29 |
};
|
| 30 |
|
| 31 |
+
// Accept either 'canvas' (all canvas tabs highlight equally) or a more specific
|
| 32 |
+
// 'canvas-<subview>' from CanvasPage so only the active one highlights.
|
| 33 |
const isOnHome = currentPage === 'home';
|
| 34 |
const isOnChat = currentPage === 'chat';
|
| 35 |
+
const isOnCanvas = currentPage === 'canvas' || currentPage.startsWith('canvas-');
|
| 36 |
+
const canvasSub = currentPage.startsWith('canvas-') ? currentPage.slice(7) : null;
|
| 37 |
+
const tabActive = (sub) => isOnCanvas && (canvasSub === null ? false : canvasSub === sub);
|
| 38 |
|
| 39 |
return (
|
| 40 |
<header className="floating-header app-header">
|
|
|
|
| 66 |
|
| 67 |
<div className="canvas-tabs chat-view-tabs">
|
| 68 |
<button className={`tab ${isOnChat ? 'active' : ''}`} onClick={onNavigateToChat}>Chat</button>
|
| 69 |
+
<button className={`tab ${tabActive('insights') ? 'active' : ''}`} onClick={() => goToCanvas('insights')}>Insights</button>
|
| 70 |
+
<button className={`tab ${tabActive('workspace') ? 'active' : ''}`} onClick={() => goToCanvas('workspace')}>Workspace</button>
|
| 71 |
+
<button className={`tab ${tabActive('deliverables') ? 'active' : ''}`} onClick={() => goToCanvas('deliverables')}>Deliverables</button>
|
| 72 |
</div>
|
| 73 |
|
| 74 |
<div className="header-right">
|
phd-advisor-frontend/src/components/canvas/CanvasDeliverables.js
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Deliverables view — pick a template, fill in structured sections, export.
|
| 2 |
+
// Static "missing" checks run locally. AI checks are stubbed (need LLM endpoint).
|
| 3 |
+
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
| 4 |
+
import ReactMarkdown from 'react-markdown';
|
| 5 |
+
import remarkGfm from 'remark-gfm';
|
| 6 |
+
import Icon from './CanvasIcon';
|
| 7 |
+
|
| 8 |
+
const fireToast = (msg, kind = 'success') =>
|
| 9 |
+
window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
|
| 10 |
+
|
| 11 |
+
const STORE_KEY = 'canvas-deliverables-v1';
|
| 12 |
+
|
| 13 |
+
// ---------- Template definitions ----------
|
| 14 |
+
const TEMPLATES = [
|
| 15 |
+
{
|
| 16 |
+
id: 'research-paper',
|
| 17 |
+
name: 'Research Paper',
|
| 18 |
+
desc: 'Abstract → Introduction → Methods → Results → Discussion → References',
|
| 19 |
+
icon: 'book',
|
| 20 |
+
mode: 'paper',
|
| 21 |
+
sections: [
|
| 22 |
+
{ id: 'abstract', name: 'Abstract', target: 250, hint: 'One paragraph: question, method, finding, implication.', checks: ['hasNumber', 'hasFinding'] },
|
| 23 |
+
{ id: 'intro', name: 'Introduction', target: 1000, hint: 'Frame the problem, state the gap, name your contribution.', checks: ['hasGap', 'hasCitation'] },
|
| 24 |
+
{ id: 'methods', name: 'Methods', target: 800, hint: 'Reproducibility-first: subjects, materials, procedure, analysis.', checks: ['hasCitation', 'hasNumber'] },
|
| 25 |
+
{ id: 'results', name: 'Results', target: 800, hint: 'Lead with the effect. Numbers + figure refs. No interpretation here.', checks: ['hasNumber', 'hasFigure'] },
|
| 26 |
+
{ id: 'discussion', name: 'Discussion', target: 1000, hint: 'What it means, what it doesn\'t, limits, future work.', checks: ['hasLimit', 'hasCitation'] },
|
| 27 |
+
{ id: 'refs', name: 'References', target: 0, hint: 'Bibliography list. Drop @keys here from the Bibliography widget.', checks: [] },
|
| 28 |
+
],
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
id: 'nsf-grfp',
|
| 32 |
+
name: 'NSF GRFP',
|
| 33 |
+
desc: 'Personal Statement (3 pages) + Research Plan (2 pages)',
|
| 34 |
+
icon: 'award',
|
| 35 |
+
mode: 'document',
|
| 36 |
+
sections: [
|
| 37 |
+
{ id: 'personal', name: 'Personal Statement', target: 1500, hint: 'Background, experiences, broader impacts. Write as a story.', checks: ['hasBroaderImpacts'] },
|
| 38 |
+
{ id: 'research', name: 'Research Plan', target: 1000, hint: 'Question, hypothesis, approach, intellectual merit.', checks: ['hasHypothesis', 'hasMerit'] },
|
| 39 |
+
],
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
id: 'conference-abstract',
|
| 43 |
+
name: 'Conference Abstract',
|
| 44 |
+
desc: 'Single section, 250 words. Lead with the result.',
|
| 45 |
+
icon: 'send',
|
| 46 |
+
mode: 'document',
|
| 47 |
+
sections: [
|
| 48 |
+
{ id: 'abs', name: 'Abstract', target: 250, hint: 'One paragraph. Lead with finding, end with implication.', checks: ['hasFinding', 'hasNumber'] },
|
| 49 |
+
],
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
id: 'defense-slides',
|
| 53 |
+
name: 'Defense Slides',
|
| 54 |
+
desc: 'Title → Outline → Background → Question → Methods → Results → Discussion → Q&A',
|
| 55 |
+
icon: 'kanban',
|
| 56 |
+
mode: 'slides',
|
| 57 |
+
sections: [
|
| 58 |
+
{ id: 'title', name: 'Title slide', target: 30, hint: 'Title, your name, advisor, date.', checks: [] },
|
| 59 |
+
{ id: 'outline', name: 'Outline', target: 60, hint: '5–7 bullet points covering the talk arc.', checks: [] },
|
| 60 |
+
{ id: 'background', name: 'Background', target: 200, hint: 'Just enough context to follow the question.', checks: ['hasCitation'] },
|
| 61 |
+
{ id: 'question', name: 'Question', target: 80, hint: 'Single sentence, falsifiable.', checks: [] },
|
| 62 |
+
{ id: 'methods', name: 'Methods', target: 200, hint: 'High-level. Save details for backup slides.', checks: ['hasNumber'] },
|
| 63 |
+
{ id: 'results', name: 'Results', target: 300, hint: 'One slide per finding. Lead with the headline.', checks: ['hasFigure', 'hasNumber'] },
|
| 64 |
+
{ id: 'discussion', name: 'Discussion', target: 200, hint: 'Implications + limits + next steps.', checks: ['hasLimit'] },
|
| 65 |
+
{ id: 'qa', name: 'Anticipated Q&A', target: 300, hint: 'Hardest 5 questions and your answers.', checks: [] },
|
| 66 |
+
],
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
id: 'thesis-chapter',
|
| 70 |
+
name: 'Thesis Chapter',
|
| 71 |
+
desc: 'Standard chapter scaffolding for a dissertation.',
|
| 72 |
+
icon: 'book',
|
| 73 |
+
mode: 'paper',
|
| 74 |
+
sections: [
|
| 75 |
+
{ id: 'overview', name: 'Overview', target: 200, hint: 'What this chapter does and why it\'s here.', checks: [] },
|
| 76 |
+
{ id: 'background', name: 'Background', target: 1500, hint: 'Lit review focused on this chapter\'s question.', checks: ['hasCitation'] },
|
| 77 |
+
{ id: 'methods', name: 'Methods', target: 1500, hint: 'Reproducibility-first.', checks: ['hasCitation', 'hasNumber'] },
|
| 78 |
+
{ id: 'results', name: 'Results', target: 2000, hint: 'Findings + figures.', checks: ['hasFigure', 'hasNumber'] },
|
| 79 |
+
{ id: 'discussion', name: 'Discussion', target: 1500, hint: 'How it fits the larger thesis.', checks: ['hasLimit'] },
|
| 80 |
+
],
|
| 81 |
+
},
|
| 82 |
+
];
|
| 83 |
+
|
| 84 |
+
// ---------- Static check rules ----------
|
| 85 |
+
const CHECKS = {
|
| 86 |
+
hasNumber: { test: (s) => /\d/.test(s), label: 'Mentions at least one number' },
|
| 87 |
+
hasCitation: { test: (s) => /@\w+/.test(s), label: 'Cites at least one source (@key)' },
|
| 88 |
+
hasFinding: { test: (s) => /\b(we (find|show|report|demonstrate)|finding|result)/i.test(s), label: 'States a finding' },
|
| 89 |
+
hasGap: { test: (s) => /\b(gap|lack|missing|unknown|unclear|despite)/i.test(s), label: 'Names a gap' },
|
| 90 |
+
hasFigure: { test: (s) => /\b(fig(ure)?|table)\.?\s*\d/i.test(s), label: 'References a figure or table' },
|
| 91 |
+
hasLimit: { test: (s) => /\b(limit|caveat|however|future work|did not|cannot)/i.test(s), label: 'Acknowledges a limit' },
|
| 92 |
+
hasBroaderImpacts: { test: (s) => /\b(broader impact|outreach|community|underrepresented|access)/i.test(s), label: 'Addresses broader impacts' },
|
| 93 |
+
hasHypothesis: { test: (s) => /\b(hypothes|predict)/i.test(s), label: 'States a hypothesis or prediction' },
|
| 94 |
+
hasMerit: { test: (s) => /\b(intellectual merit|novel|advances|contribut)/i.test(s), label: 'Frames intellectual merit' },
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
const wordCount = (s) => (s || '').trim().split(/\s+/).filter(Boolean).length;
|
| 98 |
+
|
| 99 |
+
// ---------- Exporters ----------
|
| 100 |
+
const exportMarkdown = (template, sections) => {
|
| 101 |
+
return [
|
| 102 |
+
`# ${template.name}\n`,
|
| 103 |
+
...template.sections.map(s => `## ${s.name}\n\n${sections[s.id] || ''}\n`),
|
| 104 |
+
].join('\n');
|
| 105 |
+
};
|
| 106 |
+
const exportLatex = (template, sections) => {
|
| 107 |
+
return [
|
| 108 |
+
'\\documentclass{article}',
|
| 109 |
+
`\\title{${template.name}}`,
|
| 110 |
+
'\\begin{document}',
|
| 111 |
+
'\\maketitle',
|
| 112 |
+
...template.sections.map(s => `\n\\section{${s.name}}\n${sections[s.id] || ''}\n`),
|
| 113 |
+
'\\end{document}',
|
| 114 |
+
].join('\n');
|
| 115 |
+
};
|
| 116 |
+
const exportHtml = (template, sections) => {
|
| 117 |
+
return [
|
| 118 |
+
'<!doctype html>',
|
| 119 |
+
`<html><head><title>${template.name}</title></head><body>`,
|
| 120 |
+
`<h1>${template.name}</h1>`,
|
| 121 |
+
...template.sections.map(s => `<section><h2>${s.name}</h2><p>${(sections[s.id] || '').replace(/\n/g, '<br>')}</p></section>`),
|
| 122 |
+
'</body></html>',
|
| 123 |
+
].join('\n');
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
+
const downloadFile = (filename, mime, contents) => {
|
| 127 |
+
const blob = new Blob([contents], { type: mime });
|
| 128 |
+
const a = document.createElement('a');
|
| 129 |
+
a.href = URL.createObjectURL(blob);
|
| 130 |
+
a.download = filename;
|
| 131 |
+
a.click();
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
// ---------- Component ----------
|
| 135 |
+
const DeliverablesView = ({ allStates }) => {
|
| 136 |
+
const [store, setStore] = useState(() => {
|
| 137 |
+
try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); } catch { return {}; }
|
| 138 |
+
});
|
| 139 |
+
useEffect(() => { localStorage.setItem(STORE_KEY, JSON.stringify(store)); }, [store]);
|
| 140 |
+
|
| 141 |
+
const activeId = store.activeTemplateId;
|
| 142 |
+
const template = TEMPLATES.find(t => t.id === activeId);
|
| 143 |
+
const sections = (store.templates && store.templates[activeId]) || {};
|
| 144 |
+
const [activeSectionId, setActiveSectionId] = useState(template?.sections[0]?.id);
|
| 145 |
+
const [generatingAi, setGeneratingAi] = useState(false);
|
| 146 |
+
|
| 147 |
+
// Keep activeSectionId valid when template changes (re-mount from localStorage,
|
| 148 |
+
// template switch, etc.). Falls back to the first section.
|
| 149 |
+
useEffect(() => {
|
| 150 |
+
if (!template) return;
|
| 151 |
+
const valid = template.sections.some(s => s.id === activeSectionId);
|
| 152 |
+
if (!valid) setActiveSectionId(template.sections[0].id);
|
| 153 |
+
}, [activeId, template, activeSectionId]);
|
| 154 |
+
|
| 155 |
+
// TODO(LLM): wire `runAiPass` to backend that returns per-section "missing" notes.
|
| 156 |
+
// const runAiPass = async () => {
|
| 157 |
+
// const res = await fetch(`${process.env.REACT_APP_API_URL}/api/canvas/deliverable-check`, {
|
| 158 |
+
// method: 'POST',
|
| 159 |
+
// body: JSON.stringify({ template: template.id, sections, canvas: allStates }),
|
| 160 |
+
// });
|
| 161 |
+
// const { notes } = await res.json();
|
| 162 |
+
// setStore({ ...store, templates: { ...store.templates, [activeId]: { ...sections, _aiNotes: notes } } });
|
| 163 |
+
// };
|
| 164 |
+
const runAiPass = () => {
|
| 165 |
+
setGeneratingAi(true);
|
| 166 |
+
setTimeout(() => {
|
| 167 |
+
const notes = template.sections.map(s => {
|
| 168 |
+
const text = sections[s.id] || '';
|
| 169 |
+
const wc = wordCount(text);
|
| 170 |
+
if (wc === 0) return { sectionId: s.id, msg: `Empty — start with: "${s.hint}"` };
|
| 171 |
+
if (wc < s.target * 0.3) return { sectionId: s.id, msg: `Thin (${wc} words). Target ${s.target}.` };
|
| 172 |
+
return { sectionId: s.id, msg: `Looks reasonable for length (${wc} words). LLM-pass would suggest specifics here.` };
|
| 173 |
+
});
|
| 174 |
+
setStore(prev => ({
|
| 175 |
+
...prev,
|
| 176 |
+
templates: { ...prev.templates, [activeId]: { ...sections, _aiNotes: notes } },
|
| 177 |
+
}));
|
| 178 |
+
setGeneratingAi(false);
|
| 179 |
+
fireToast('AI pass complete (stub)');
|
| 180 |
+
}, 700);
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
const pickTemplate = (id) => {
|
| 184 |
+
setStore({ ...store, activeTemplateId: id });
|
| 185 |
+
setActiveSectionId(TEMPLATES.find(t => t.id === id).sections[0].id);
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
const updateSection = (id, value) => {
|
| 189 |
+
setStore(prev => ({
|
| 190 |
+
...prev,
|
| 191 |
+
templates: {
|
| 192 |
+
...prev.templates,
|
| 193 |
+
[activeId]: { ...(prev.templates?.[activeId] || {}), [id]: value },
|
| 194 |
+
},
|
| 195 |
+
}));
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
const exportAs = (format) => {
|
| 199 |
+
const filename = `${template.name.replace(/\s+/g, '_')}.${format === 'latex' ? 'tex' : format === 'markdown' ? 'md' : 'html'}`;
|
| 200 |
+
const mime = format === 'html' ? 'text/html' : 'text/plain';
|
| 201 |
+
const contents = format === 'markdown' ? exportMarkdown(template, sections)
|
| 202 |
+
: format === 'latex' ? exportLatex(template, sections)
|
| 203 |
+
: exportHtml(template, sections);
|
| 204 |
+
downloadFile(filename, mime, contents);
|
| 205 |
+
fireToast(`Exported ${filename}`);
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
// Aggregated word count
|
| 209 |
+
const totalWords = template ? template.sections.reduce((sum, s) => sum + wordCount(sections[s.id]), 0) : 0;
|
| 210 |
+
const totalTarget = template ? template.sections.reduce((sum, s) => sum + s.target, 0) : 0;
|
| 211 |
+
|
| 212 |
+
// Insertable elements from the canvas (citations, quotes, outline nodes, chapter drafts)
|
| 213 |
+
const insertables = useMemo(() => {
|
| 214 |
+
const items = [];
|
| 215 |
+
(allStates?.bibliography?.entries || []).forEach(e => items.push({ kind: 'cite', label: `${e.title}`, snippet: ` (${e.authors}, ${e.year}; @${e.key})` }));
|
| 216 |
+
(allStates?.highlights?.items || []).forEach(h => items.push({ kind: 'quote', label: h.text.slice(0, 60), snippet: `"${h.text}"${h.citeKey ? ` (@${h.citeKey})` : ''}` }));
|
| 217 |
+
(allStates?.outline?.items || []).forEach(o => items.push({ kind: 'outline', label: o.text || '(empty)', snippet: '\n' + ' '.repeat(o.depth) + '- ' + (o.text || '') }));
|
| 218 |
+
(allStates?.writing?.chapters || []).forEach(c => items.push({ kind: 'draft', label: c.name, snippet: c.draft || '' }));
|
| 219 |
+
return items;
|
| 220 |
+
}, [allStates]);
|
| 221 |
+
|
| 222 |
+
const insertIntoActive = (snippet) => {
|
| 223 |
+
if (!activeSectionId) return;
|
| 224 |
+
const cur = sections[activeSectionId] || '';
|
| 225 |
+
updateSection(activeSectionId, cur + (cur && !cur.endsWith('\n') ? ' ' : '') + snippet);
|
| 226 |
+
fireToast('Inserted into ' + template.sections.find(s => s.id === activeSectionId)?.name);
|
| 227 |
+
};
|
| 228 |
+
|
| 229 |
+
// Empty state — pick a template
|
| 230 |
+
if (!template) {
|
| 231 |
+
return (
|
| 232 |
+
<>
|
| 233 |
+
<div className="page-header">
|
| 234 |
+
<div>
|
| 235 |
+
<h1 className="page-title">Deliverables</h1>
|
| 236 |
+
<div className="page-sub">Pick a template; bring elements in from your canvas; export when ready.</div>
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
<div className="canvas-presets-grid">
|
| 240 |
+
{TEMPLATES.map(t => (
|
| 241 |
+
<button key={t.id} className="canvas-preset-card" onClick={() => pickTemplate(t.id)}>
|
| 242 |
+
<div className="canvas-preset-icon"><Icon name={t.icon} size={18}/></div>
|
| 243 |
+
<div className="canvas-preset-content">
|
| 244 |
+
<div className="canvas-preset-name">{t.name}</div>
|
| 245 |
+
<div className="canvas-preset-desc">{t.desc}</div>
|
| 246 |
+
<div className="canvas-preset-meta">{t.sections.length} sections</div>
|
| 247 |
+
</div>
|
| 248 |
+
</button>
|
| 249 |
+
))}
|
| 250 |
+
</div>
|
| 251 |
+
{/* TODO(LLM): "Upload project brief → AI generates a custom outline" button.
|
| 252 |
+
Wire to backend that returns a synthesized template:
|
| 253 |
+
const onUpload = async (file) => {
|
| 254 |
+
const text = await file.text();
|
| 255 |
+
const res = await fetch(`${API}/api/canvas/outline-from-brief`, { method:'POST', body: text });
|
| 256 |
+
const { template, sections } = await res.json();
|
| 257 |
+
// Inject as a new template into TEMPLATES at runtime, then pickTemplate(template.id);
|
| 258 |
+
};
|
| 259 |
+
*/}
|
| 260 |
+
<div className="canvas-presets" style={{ marginTop: 18 }}>
|
| 261 |
+
<div className="canvas-presets-head">
|
| 262 |
+
<div className="canvas-presets-title">From a project brief</div>
|
| 263 |
+
<div className="canvas-presets-sub">Upload your brief and the AI will draft a custom outline. <em>(Needs LLM endpoint — coming soon.)</em></div>
|
| 264 |
+
</div>
|
| 265 |
+
<button className="btn" disabled title="Needs LLM endpoint">
|
| 266 |
+
<Icon name="download" size={13} style={{ transform: 'rotate(180deg)' }}/>Upload project brief
|
| 267 |
+
</button>
|
| 268 |
+
</div>
|
| 269 |
+
</>
|
| 270 |
+
);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
const aiNotes = sections._aiNotes;
|
| 274 |
+
|
| 275 |
+
// Shared header + insertables panel — used by all editor modes.
|
| 276 |
+
const Header = (
|
| 277 |
+
<div className="page-header">
|
| 278 |
+
<div>
|
| 279 |
+
<button className="btn btn-ghost" style={{ padding: '4px 8px', fontSize: 12, marginBottom: 4, color: 'var(--canvas-text-3)' }} onClick={() => setStore({ ...store, activeTemplateId: undefined })}>
|
| 280 |
+
<Icon name="back" size={12}/>Templates
|
| 281 |
+
</button>
|
| 282 |
+
<h1 className="page-title">{template.name}</h1>
|
| 283 |
+
<div className="page-sub">
|
| 284 |
+
{totalWords} / {totalTarget} words · {template.sections.length} {template.mode === 'slides' ? 'slides' : 'sections'}
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
| 288 |
+
<button className="btn btn-ghost" onClick={runAiPass} disabled={generatingAi} title="AI check (stub)">
|
| 289 |
+
{generatingAi ? <><div className="spinner"/></> : <Icon name="sparkles" size={13}/>}
|
| 290 |
+
AI check
|
| 291 |
+
</button>
|
| 292 |
+
<div style={{ position: 'relative' }}>
|
| 293 |
+
<details className="canvas-export-menu">
|
| 294 |
+
<summary className="btn btn-primary"><Icon name="download" size={13}/>Export</summary>
|
| 295 |
+
<div className="canvas-export-menu-list">
|
| 296 |
+
<button onClick={() => exportAs('markdown')}>Markdown (.md)</button>
|
| 297 |
+
<button onClick={() => exportAs('latex')}>LaTeX (.tex)</button>
|
| 298 |
+
<button onClick={() => exportAs('html')}>HTML (.html)</button>
|
| 299 |
+
</div>
|
| 300 |
+
</details>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
);
|
| 305 |
+
|
| 306 |
+
const InsertPanel = (
|
| 307 |
+
<div className="deliverable-insertables">
|
| 308 |
+
<div style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, marginBottom: 8 }}>
|
| 309 |
+
From canvas · {insertables.length}
|
| 310 |
+
</div>
|
| 311 |
+
{insertables.length === 0 && (
|
| 312 |
+
<div style={{ padding: 12, fontSize: 11.5, color: 'var(--canvas-text-3)', background: 'var(--canvas-surface)', border: '1px dashed var(--canvas-border-2)', borderRadius: 7 }}>
|
| 313 |
+
Add a Bibliography, Highlights, Outline, or Writing widget to your canvas; their content shows up here for one-click insert.
|
| 314 |
+
</div>
|
| 315 |
+
)}
|
| 316 |
+
{insertables.map((it, i) => (
|
| 317 |
+
<button key={i} onClick={() => insertIntoActive(it.snippet)} className="canvas-insert-row">
|
| 318 |
+
<span className="tag-pill">{it.kind}</span>
|
| 319 |
+
<span style={{ flex: 1, fontSize: 11.5, color: 'var(--canvas-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.label}</span>
|
| 320 |
+
<Icon name="plus" size={12} style={{ color: 'var(--canvas-text-3)' }}/>
|
| 321 |
+
</button>
|
| 322 |
+
))}
|
| 323 |
+
</div>
|
| 324 |
+
);
|
| 325 |
+
|
| 326 |
+
// ---------- SLIDES MODE — PowerPoint / Google Slides feel ----------
|
| 327 |
+
if (template.mode === 'slides') {
|
| 328 |
+
const activeIdx = template.sections.findIndex(s => s.id === activeSectionId);
|
| 329 |
+
const active = template.sections[activeIdx] || template.sections[0];
|
| 330 |
+
const text = sections[active.id] || '';
|
| 331 |
+
const aiForSlide = aiNotes && aiNotes.find(n => n.sectionId === active.id);
|
| 332 |
+
// Render body as bullet points if it contains line breaks; otherwise as a paragraph.
|
| 333 |
+
const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean);
|
| 334 |
+
|
| 335 |
+
return (
|
| 336 |
+
<>
|
| 337 |
+
{Header}
|
| 338 |
+
<div className="deliverable-slides-grid">
|
| 339 |
+
{/* Slide thumbnails */}
|
| 340 |
+
<div className="slide-thumbs">
|
| 341 |
+
<div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, padding: '0 4px 6px' }}>
|
| 342 |
+
{template.sections.length} slides
|
| 343 |
+
</div>
|
| 344 |
+
{template.sections.map((s, i) => {
|
| 345 |
+
const t = sections[s.id] || '';
|
| 346 |
+
const tLines = t.split(/\n+/).map(l => l.trim()).filter(Boolean);
|
| 347 |
+
return (
|
| 348 |
+
<button key={s.id}
|
| 349 |
+
className={`slide-thumb ${s.id === active.id ? 'active' : ''}`}
|
| 350 |
+
onClick={() => setActiveSectionId(s.id)}
|
| 351 |
+
title={s.name}>
|
| 352 |
+
<div className="slide-thumb-num">{i + 1}</div>
|
| 353 |
+
<div className="slide-thumb-canvas">
|
| 354 |
+
<div className="slide-thumb-title">{s.name}</div>
|
| 355 |
+
<div className="slide-thumb-body">
|
| 356 |
+
{tLines.slice(0, 3).map((l, j) => <div key={j}>• {l.slice(0, 30)}</div>)}
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
</button>
|
| 360 |
+
);
|
| 361 |
+
})}
|
| 362 |
+
</div>
|
| 363 |
+
|
| 364 |
+
{/* Big slide canvas */}
|
| 365 |
+
<div>
|
| 366 |
+
<div className="slide-canvas-wrap">
|
| 367 |
+
<div className="slide-canvas">
|
| 368 |
+
<div className="slide-canvas-title">{active.name}</div>
|
| 369 |
+
<div className="slide-canvas-body">
|
| 370 |
+
{lines.length === 0 ? (
|
| 371 |
+
<div className="slide-placeholder">{active.hint}</div>
|
| 372 |
+
) : lines.length === 1 ? (
|
| 373 |
+
<div className="slide-paragraph">{lines[0]}</div>
|
| 374 |
+
) : (
|
| 375 |
+
<ul>{lines.map((l, j) => <li key={j}>{l}</li>)}</ul>
|
| 376 |
+
)}
|
| 377 |
+
</div>
|
| 378 |
+
<div className="slide-canvas-footer">{activeIdx + 1} / {template.sections.length}</div>
|
| 379 |
+
</div>
|
| 380 |
+
</div>
|
| 381 |
+
|
| 382 |
+
{/* Edit pane below the canvas (so the slide stays the focus) */}
|
| 383 |
+
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
| 384 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 385 |
+
<span style={{ fontSize: 11, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>
|
| 386 |
+
Slide content
|
| 387 |
+
</span>
|
| 388 |
+
<span style={{ fontSize: 11, color: 'var(--canvas-text-3)' }}>· One bullet per line</span>
|
| 389 |
+
<span style={{ flex: 1 }}/>
|
| 390 |
+
<span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 10, color: 'var(--canvas-text-3)' }}>
|
| 391 |
+
{wordCount(text)}{active.target ? `/${active.target}` : ''} words
|
| 392 |
+
</span>
|
| 393 |
+
</div>
|
| 394 |
+
<textarea
|
| 395 |
+
className="textarea"
|
| 396 |
+
value={text}
|
| 397 |
+
onChange={e => updateSection(active.id, e.target.value)}
|
| 398 |
+
placeholder={active.hint}
|
| 399 |
+
style={{ minHeight: 110, fontSize: 13, lineHeight: 1.6 }}
|
| 400 |
+
/>
|
| 401 |
+
{(active.checks || []).length > 0 && (
|
| 402 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
| 403 |
+
{active.checks.map(c => {
|
| 404 |
+
const check = CHECKS[c];
|
| 405 |
+
if (!check) return null;
|
| 406 |
+
const passed = check.test(text);
|
| 407 |
+
return (
|
| 408 |
+
<span key={c} className="check-pill" data-passed={passed}>
|
| 409 |
+
{passed ? <Icon name="check" size={10}/> : <span style={{ width: 10, height: 10, borderRadius: 2, border: '1px solid currentColor' }}/>}
|
| 410 |
+
{check.label}
|
| 411 |
+
</span>
|
| 412 |
+
);
|
| 413 |
+
})}
|
| 414 |
+
</div>
|
| 415 |
+
)}
|
| 416 |
+
{aiForSlide && (
|
| 417 |
+
<div className="review" style={{ borderLeftColor: 'var(--canvas-accent)' }}>
|
| 418 |
+
<span className="review-tag" style={{ color: 'var(--canvas-accent)' }}>AI suggestion · stub</span>
|
| 419 |
+
{aiForSlide.msg}
|
| 420 |
+
</div>
|
| 421 |
+
)}
|
| 422 |
+
</div>
|
| 423 |
+
</div>
|
| 424 |
+
|
| 425 |
+
{InsertPanel}
|
| 426 |
+
</div>
|
| 427 |
+
</>
|
| 428 |
+
);
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
// ---------- PAPER / DOCUMENT MODE — Notion-style single-surface page ----------
|
| 432 |
+
const paperLike = template.mode === 'paper';
|
| 433 |
+
return (
|
| 434 |
+
<>
|
| 435 |
+
{Header}
|
| 436 |
+
<div className="notion-deliverable-grid">
|
| 437 |
+
{/* TOC sidebar — subtle, no boxes */}
|
| 438 |
+
<div className="notion-toc">
|
| 439 |
+
<div className="notion-toc-label">On this page</div>
|
| 440 |
+
{template.sections.map(s => {
|
| 441 |
+
const wc = wordCount(sections[s.id]);
|
| 442 |
+
return (
|
| 443 |
+
<button key={s.id}
|
| 444 |
+
className={`notion-toc-link ${activeSectionId === s.id ? 'active' : ''}`}
|
| 445 |
+
onClick={() => {
|
| 446 |
+
setActiveSectionId(s.id);
|
| 447 |
+
const el = document.getElementById(`notion-section-${s.id}`);
|
| 448 |
+
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
| 449 |
+
}}>
|
| 450 |
+
<span className="notion-toc-link-text">{s.name}</span>
|
| 451 |
+
{wc > 0 && <span className="notion-toc-link-count">{wc}</span>}
|
| 452 |
+
</button>
|
| 453 |
+
);
|
| 454 |
+
})}
|
| 455 |
+
</div>
|
| 456 |
+
|
| 457 |
+
{/* The page itself — what you see is what you edit */}
|
| 458 |
+
<div className={`notion-page-wrap ${paperLike ? 'paper' : ''}`}>
|
| 459 |
+
<div className={`notion-page ${paperLike ? 'serif' : ''}`}>
|
| 460 |
+
<h1 className="notion-page-title">{template.name}</h1>
|
| 461 |
+
<div className="notion-page-meta">
|
| 462 |
+
{totalWords} words · {template.sections.length} sections{paperLike ? ' · academic paper' : ''}
|
| 463 |
+
</div>
|
| 464 |
+
{template.sections.map(s => {
|
| 465 |
+
const text = sections[s.id] || '';
|
| 466 |
+
const aiForSection = aiNotes ? aiNotes.find(n => n.sectionId === s.id) : null;
|
| 467 |
+
const failed = (s.checks || []).filter(c => CHECKS[c] && !CHECKS[c].test(text));
|
| 468 |
+
return (
|
| 469 |
+
<div key={s.id} id={`notion-section-${s.id}`} className="notion-block">
|
| 470 |
+
<h2 className={`notion-h2 ${paperLike ? 'serif' : ''}`}>{s.name}</h2>
|
| 471 |
+
{!text && <div className="notion-hint">{s.hint}</div>}
|
| 472 |
+
<NotionTextarea
|
| 473 |
+
value={text}
|
| 474 |
+
onChange={(v) => updateSection(s.id, v)}
|
| 475 |
+
placeholder={`Start writing ${s.name.toLowerCase()}…`}
|
| 476 |
+
serif={paperLike}
|
| 477 |
+
/>
|
| 478 |
+
{(s.checks || []).length > 0 && (
|
| 479 |
+
<div className="notion-block-meta">
|
| 480 |
+
{s.checks.map(c => {
|
| 481 |
+
const check = CHECKS[c];
|
| 482 |
+
if (!check) return null;
|
| 483 |
+
const passed = check.test(text);
|
| 484 |
+
return (
|
| 485 |
+
<span key={c} className="check-pill" data-passed={passed}>
|
| 486 |
+
{passed ? <Icon name="check" size={10}/> : <span style={{ width: 10, height: 10, borderRadius: 2, border: '1px solid currentColor' }}/>}
|
| 487 |
+
{check.label}
|
| 488 |
+
</span>
|
| 489 |
+
);
|
| 490 |
+
})}
|
| 491 |
+
{s.target > 0 && (
|
| 492 |
+
<span className="check-pill" data-passed={wordCount(text) >= s.target * 0.7}>
|
| 493 |
+
{wordCount(text)} / {s.target} words
|
| 494 |
+
</span>
|
| 495 |
+
)}
|
| 496 |
+
</div>
|
| 497 |
+
)}
|
| 498 |
+
{aiForSection && (
|
| 499 |
+
<div className="notion-callout">
|
| 500 |
+
<Icon name="sparkles" size={14}/>
|
| 501 |
+
<div>
|
| 502 |
+
<div className="notion-callout-label">AI suggestion · stub</div>
|
| 503 |
+
<div>{aiForSection.msg}</div>
|
| 504 |
+
</div>
|
| 505 |
+
</div>
|
| 506 |
+
)}
|
| 507 |
+
</div>
|
| 508 |
+
);
|
| 509 |
+
})}
|
| 510 |
+
</div>
|
| 511 |
+
</div>
|
| 512 |
+
|
| 513 |
+
{InsertPanel}
|
| 514 |
+
</div>
|
| 515 |
+
</>
|
| 516 |
+
);
|
| 517 |
+
};
|
| 518 |
+
|
| 519 |
+
// Auto-growing textarea styled to be invisible on the Notion page —
|
| 520 |
+
// looks like body text until the user clicks in.
|
| 521 |
+
function NotionTextarea({ value, onChange, placeholder, serif }) {
|
| 522 |
+
const ref = useRef(null);
|
| 523 |
+
useEffect(() => {
|
| 524 |
+
if (!ref.current) return;
|
| 525 |
+
ref.current.style.height = 'auto';
|
| 526 |
+
ref.current.style.height = ref.current.scrollHeight + 'px';
|
| 527 |
+
}, [value]);
|
| 528 |
+
return (
|
| 529 |
+
<textarea
|
| 530 |
+
ref={ref}
|
| 531 |
+
className={`notion-text ${serif ? 'serif' : ''}`}
|
| 532 |
+
value={value}
|
| 533 |
+
onChange={(e) => onChange(e.target.value)}
|
| 534 |
+
placeholder={placeholder}
|
| 535 |
+
rows={1}
|
| 536 |
+
/>
|
| 537 |
+
);
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
export default DeliverablesView;
|
phd-advisor-frontend/src/components/canvas/CanvasModals.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useState, useMemo } from 'react';
|
| 2 |
import Icon from './CanvasIcon';
|
| 3 |
import { WIDGET_CATALOG, CATEGORIES } from './canvasData';
|
| 4 |
|
|
@@ -506,14 +506,72 @@ export function BudgetItemModal({ data, onClose }) {
|
|
| 506 |
);
|
| 507 |
}
|
| 508 |
|
| 509 |
-
// ---------- Note ----------
|
| 510 |
export function NoteModal({ data, onClose }) {
|
| 511 |
const init = data.initial || {};
|
| 512 |
const [text, setT] = useState(init.text || '');
|
| 513 |
const [tag, setG] = useState(init.tag || '');
|
| 514 |
const [linkTo, setL] = useState(init.linkTo || '');
|
|
|
|
|
|
|
|
|
|
| 515 |
const editing = !!init.id;
|
| 516 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
const submit = () => {
|
| 518 |
if (!text.trim()) return;
|
| 519 |
data.onSave({ text: text.trim(), tag: tag.trim(), linkTo: linkTo.trim() });
|
|
@@ -532,8 +590,39 @@ export function NoteModal({ data, onClose }) {
|
|
| 532 |
</div>
|
| 533 |
<div className="modal-body">
|
| 534 |
<div className="form-grid">
|
| 535 |
-
<div className="form-row">
|
| 536 |
-
<textarea
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
</div>
|
| 538 |
<div className="form-grid two">
|
| 539 |
<div className="form-row">
|
|
@@ -802,6 +891,126 @@ export function PaletteModal({ data, onClose }) {
|
|
| 802 |
);
|
| 803 |
}
|
| 804 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
// ---------- Command palette ----------
|
| 806 |
export function CommandPaletteModal({ data, onClose }) {
|
| 807 |
const [q, setQ] = useState('');
|
|
|
|
| 1 |
+
import React, { useState, useMemo, useRef } from 'react';
|
| 2 |
import Icon from './CanvasIcon';
|
| 3 |
import { WIDGET_CATALOG, CATEGORIES } from './canvasData';
|
| 4 |
|
|
|
|
| 506 |
);
|
| 507 |
}
|
| 508 |
|
| 509 |
+
// ---------- Note (with @mention autocomplete) ----------
|
| 510 |
export function NoteModal({ data, onClose }) {
|
| 511 |
const init = data.initial || {};
|
| 512 |
const [text, setT] = useState(init.text || '');
|
| 513 |
const [tag, setG] = useState(init.tag || '');
|
| 514 |
const [linkTo, setL] = useState(init.linkTo || '');
|
| 515 |
+
const [mention, setMention] = useState(null); // { start, query, choices }
|
| 516 |
+
const [mentionIdx, setMentionIdx] = useState(0);
|
| 517 |
+
const taRef = useRef(null);
|
| 518 |
const editing = !!init.id;
|
| 519 |
|
| 520 |
+
// Pull mention sources from the persisted canvas state so the modal stays self-contained.
|
| 521 |
+
const mentionSources = useMemo(() => {
|
| 522 |
+
try {
|
| 523 |
+
const all = JSON.parse(localStorage.getItem('canvas-states-v2') || '{}');
|
| 524 |
+
const out = [];
|
| 525 |
+
(all.bibliography?.entries || []).forEach(e => out.push({ key: '@' + e.key, label: e.title, kind: 'cite' }));
|
| 526 |
+
(all.writing?.chapters || []).forEach(c => out.push({ key: '@' + c.name.replace(/\s+/g, '-'), label: c.name, kind: 'chapter' }));
|
| 527 |
+
(all.kanban?.cards || []).forEach(c => out.push({ key: '@' + c.title.slice(0, 30).replace(/\s+/g, '-'), label: c.title, kind: 'task' }));
|
| 528 |
+
return out;
|
| 529 |
+
} catch { return []; }
|
| 530 |
+
}, []);
|
| 531 |
+
|
| 532 |
+
const onTextChange = (e) => {
|
| 533 |
+
const val = e.target.value;
|
| 534 |
+
const cursor = e.target.selectionStart;
|
| 535 |
+
setT(val);
|
| 536 |
+
// Look back from cursor for an @mention being typed
|
| 537 |
+
const before = val.slice(0, cursor);
|
| 538 |
+
const m = before.match(/@(\S*)$/);
|
| 539 |
+
if (m) {
|
| 540 |
+
const q = m[1].toLowerCase();
|
| 541 |
+
const choices = mentionSources
|
| 542 |
+
.filter(s => s.label.toLowerCase().includes(q) || s.key.toLowerCase().includes('@' + q))
|
| 543 |
+
.slice(0, 6);
|
| 544 |
+
setMention({ start: cursor - m[0].length, query: m[1], choices });
|
| 545 |
+
setMentionIdx(0);
|
| 546 |
+
} else {
|
| 547 |
+
setMention(null);
|
| 548 |
+
}
|
| 549 |
+
};
|
| 550 |
+
|
| 551 |
+
const insertMention = (item) => {
|
| 552 |
+
if (!mention) return;
|
| 553 |
+
const before = text.slice(0, mention.start);
|
| 554 |
+
const after = text.slice(mention.start + 1 + mention.query.length); // +1 for the @
|
| 555 |
+
const inserted = item.key + ' ';
|
| 556 |
+
setT(before + inserted + after);
|
| 557 |
+
setMention(null);
|
| 558 |
+
setTimeout(() => {
|
| 559 |
+
if (taRef.current) {
|
| 560 |
+
const pos = before.length + inserted.length;
|
| 561 |
+
taRef.current.setSelectionRange(pos, pos);
|
| 562 |
+
taRef.current.focus();
|
| 563 |
+
}
|
| 564 |
+
}, 0);
|
| 565 |
+
};
|
| 566 |
+
|
| 567 |
+
const onKeyDown = (e) => {
|
| 568 |
+
if (!mention || mention.choices.length === 0) return;
|
| 569 |
+
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIdx(i => Math.min(mention.choices.length - 1, i + 1)); }
|
| 570 |
+
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIdx(i => Math.max(0, i - 1)); }
|
| 571 |
+
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertMention(mention.choices[mentionIdx]); }
|
| 572 |
+
if (e.key === 'Escape') setMention(null);
|
| 573 |
+
};
|
| 574 |
+
|
| 575 |
const submit = () => {
|
| 576 |
if (!text.trim()) return;
|
| 577 |
data.onSave({ text: text.trim(), tag: tag.trim(), linkTo: linkTo.trim() });
|
|
|
|
| 590 |
</div>
|
| 591 |
<div className="modal-body">
|
| 592 |
<div className="form-grid">
|
| 593 |
+
<div className="form-row" style={{ position: 'relative' }}>
|
| 594 |
+
<textarea ref={taRef} className="textarea" autoFocus
|
| 595 |
+
style={{ minHeight: 140, fontFamily: 'var(--canvas-mono)', fontSize: 12.5 }}
|
| 596 |
+
value={text} onChange={onTextChange} onKeyDown={onKeyDown}
|
| 597 |
+
placeholder="What's the thought? Markdown welcome. Type @ to mention citations, chapters, tasks."/>
|
| 598 |
+
{mention && mention.choices.length > 0 && (
|
| 599 |
+
<div style={{
|
| 600 |
+
position: 'absolute',
|
| 601 |
+
top: '100%', left: 0, right: 0,
|
| 602 |
+
background: 'var(--canvas-surface)',
|
| 603 |
+
border: '1px solid var(--canvas-border-2)',
|
| 604 |
+
borderRadius: 7,
|
| 605 |
+
marginTop: 4,
|
| 606 |
+
boxShadow: 'var(--canvas-shadow-lg)',
|
| 607 |
+
zIndex: 10,
|
| 608 |
+
maxHeight: 200,
|
| 609 |
+
overflowY: 'auto',
|
| 610 |
+
}}>
|
| 611 |
+
{mention.choices.map((c, i) => (
|
| 612 |
+
<button key={c.key + i} onMouseEnter={() => setMentionIdx(i)} onClick={() => insertMention(c)}
|
| 613 |
+
style={{
|
| 614 |
+
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
| 615 |
+
padding: '6px 10px', textAlign: 'left',
|
| 616 |
+
background: i === mentionIdx ? 'var(--canvas-surface-2)' : 'transparent',
|
| 617 |
+
color: 'var(--canvas-text)', border: 'none', cursor: 'pointer', fontSize: 12,
|
| 618 |
+
}}>
|
| 619 |
+
<span className="tag-pill">{c.kind}</span>
|
| 620 |
+
<span style={{ fontFamily: 'var(--canvas-mono)', color: 'var(--canvas-accent)' }}>{c.key}</span>
|
| 621 |
+
<span style={{ flex: 1, color: 'var(--canvas-text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.label}</span>
|
| 622 |
+
</button>
|
| 623 |
+
))}
|
| 624 |
+
</div>
|
| 625 |
+
)}
|
| 626 |
</div>
|
| 627 |
<div className="form-grid two">
|
| 628 |
<div className="form-row">
|
|
|
|
| 891 |
);
|
| 892 |
}
|
| 893 |
|
| 894 |
+
// ---------- Global content search (notes / quotes / citations / kanban / deadlines / outline) ----------
|
| 895 |
+
export function GlobalSearchModal({ data, onClose }) {
|
| 896 |
+
const [q, setQ] = useState('');
|
| 897 |
+
const [idx, setIdx] = useState(0);
|
| 898 |
+
const states = data.states || {};
|
| 899 |
+
|
| 900 |
+
const items = useMemo(() => {
|
| 901 |
+
if (!q.trim()) return [];
|
| 902 |
+
const ql = q.toLowerCase();
|
| 903 |
+
const out = [];
|
| 904 |
+
// Notes
|
| 905 |
+
(states.notes?.items || []).forEach(n => {
|
| 906 |
+
if ((n.text || '').toLowerCase().includes(ql) || (n.tag || '').toLowerCase().includes(ql) || (n.linkTo || '').toLowerCase().includes(ql)) {
|
| 907 |
+
out.push({ kind: 'Note', label: (n.text || '').slice(0, 80), sub: n.tag ? `#${n.tag}` : '', icon: 'notes', widgetType: 'notes' });
|
| 908 |
+
}
|
| 909 |
+
});
|
| 910 |
+
// Citations
|
| 911 |
+
(states.bibliography?.entries || []).forEach(e => {
|
| 912 |
+
const blob = `${e.title} ${e.authors} ${e.journal} ${e.key}`.toLowerCase();
|
| 913 |
+
if (blob.includes(ql)) out.push({ kind: 'Citation', label: e.title, sub: `${e.authors} (${e.year})`, icon: 'book', widgetType: 'bibliography' });
|
| 914 |
+
});
|
| 915 |
+
// Kanban
|
| 916 |
+
(states.kanban?.cards || []).forEach(c => {
|
| 917 |
+
if (`${c.title} ${c.meta || ''}`.toLowerCase().includes(ql)) {
|
| 918 |
+
out.push({ kind: 'Task', label: c.title, sub: `${c.priority?.toUpperCase()} · ${c.meta || ''}`, icon: 'kanban', widgetType: 'kanban' });
|
| 919 |
+
}
|
| 920 |
+
});
|
| 921 |
+
// Deadlines
|
| 922 |
+
(states.deadlines || []).forEach(d => {
|
| 923 |
+
if (`${d.title} ${d.tag}`.toLowerCase().includes(ql)) {
|
| 924 |
+
out.push({ kind: 'Deadline', label: d.title, sub: `${d.date} · ${d.tag}`, icon: 'calendar', widgetType: 'deadlines' });
|
| 925 |
+
}
|
| 926 |
+
});
|
| 927 |
+
// Highlights
|
| 928 |
+
(states.highlights?.items || []).forEach(h => {
|
| 929 |
+
if (`${h.text} ${h.citeKey}`.toLowerCase().includes(ql)) {
|
| 930 |
+
out.push({ kind: 'Quote', label: `"${h.text}"`.slice(0, 80), sub: h.citeKey ? `@${h.citeKey}` : '', icon: 'cite', widgetType: 'highlights' });
|
| 931 |
+
}
|
| 932 |
+
});
|
| 933 |
+
// Outline
|
| 934 |
+
(states.outline?.items || []).forEach(o => {
|
| 935 |
+
if ((o.text || '').toLowerCase().includes(ql)) {
|
| 936 |
+
out.push({ kind: 'Outline', label: o.text, sub: `depth ${o.depth}`, icon: 'list', widgetType: 'outline' });
|
| 937 |
+
}
|
| 938 |
+
});
|
| 939 |
+
// Documenter
|
| 940 |
+
(states.documenter?.entries || []).forEach(e => {
|
| 941 |
+
if ((e.text || '').toLowerCase().includes(ql)) {
|
| 942 |
+
out.push({ kind: 'Journal', label: e.text.slice(0, 80), sub: e.date, icon: 'pencil', widgetType: 'documenter' });
|
| 943 |
+
}
|
| 944 |
+
});
|
| 945 |
+
return out.slice(0, 30);
|
| 946 |
+
}, [q, states]);
|
| 947 |
+
|
| 948 |
+
const jumpTo = (it) => {
|
| 949 |
+
const el = document.querySelector(`[data-widget-id^="w-"][data-widget-type="${it.widgetType}"]`)
|
| 950 |
+
|| document.querySelector(`.widget`); // fallback: scroll to first
|
| 951 |
+
if (el) {
|
| 952 |
+
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
| 953 |
+
el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)';
|
| 954 |
+
setTimeout(() => { el.style.boxShadow = ''; }, 1400);
|
| 955 |
+
}
|
| 956 |
+
onClose();
|
| 957 |
+
};
|
| 958 |
+
|
| 959 |
+
return (
|
| 960 |
+
<div className="canvas-modal" style={{ maxWidth: 600 }} onClick={(e) => e.stopPropagation()}>
|
| 961 |
+
<div style={{ padding: '16px 18px 8px', borderBottom: '1px solid var(--canvas-border)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
| 962 |
+
<Icon name="search" size={16} style={{ color: 'var(--canvas-text-3)' }}/>
|
| 963 |
+
<input
|
| 964 |
+
autoFocus
|
| 965 |
+
value={q}
|
| 966 |
+
onChange={e => { setQ(e.target.value); setIdx(0); }}
|
| 967 |
+
onKeyDown={(e) => {
|
| 968 |
+
if (e.key === 'ArrowDown') { e.preventDefault(); setIdx(i => Math.min(items.length - 1, i + 1)); }
|
| 969 |
+
if (e.key === 'ArrowUp') { e.preventDefault(); setIdx(i => Math.max(0, i - 1)); }
|
| 970 |
+
if (e.key === 'Enter' && items[idx]) jumpTo(items[idx]);
|
| 971 |
+
}}
|
| 972 |
+
placeholder="Search across notes, citations, tasks, quotes, deadlines…"
|
| 973 |
+
style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: 'var(--canvas-text)', fontSize: 15, padding: '4px 0' }}
|
| 974 |
+
/>
|
| 975 |
+
<kbd style={{ fontFamily: 'var(--canvas-mono)', fontSize: 10, color: 'var(--canvas-text-3)', background: 'var(--canvas-surface-2)', padding: '2px 6px', borderRadius: 4 }}>esc</kbd>
|
| 976 |
+
</div>
|
| 977 |
+
<div style={{ maxHeight: 420, overflowY: 'auto', padding: 6 }}>
|
| 978 |
+
{!q.trim() && (
|
| 979 |
+
<div style={{ padding: 30, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 13 }}>
|
| 980 |
+
Type to search across your canvas content.
|
| 981 |
+
</div>
|
| 982 |
+
)}
|
| 983 |
+
{q.trim() && items.length === 0 && (
|
| 984 |
+
<div style={{ padding: 30, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 13 }}>
|
| 985 |
+
No matches for "{q}".
|
| 986 |
+
</div>
|
| 987 |
+
)}
|
| 988 |
+
{items.map((it, i) => (
|
| 989 |
+
<button key={i} onClick={() => jumpTo(it)} onMouseEnter={() => setIdx(i)}
|
| 990 |
+
style={{
|
| 991 |
+
width: '100%', display: 'flex', alignItems: 'center', gap: 12,
|
| 992 |
+
padding: '8px 12px', borderRadius: 7, textAlign: 'left',
|
| 993 |
+
background: idx === i ? 'var(--canvas-surface-2)' : 'transparent',
|
| 994 |
+
color: 'var(--canvas-text)', border: 'none', cursor: 'pointer',
|
| 995 |
+
}}>
|
| 996 |
+
<div style={{ width: 24, height: 24, borderRadius: 5, background: 'var(--canvas-surface-2)', display: 'grid', placeItems: 'center', color: 'var(--canvas-accent)' }}>
|
| 997 |
+
<Icon name={it.icon} size={13}/>
|
| 998 |
+
</div>
|
| 999 |
+
<div style={{ flex: 1, minWidth: 0 }}>
|
| 1000 |
+
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{it.label}</div>
|
| 1001 |
+
<div style={{ fontSize: 11, color: 'var(--canvas-text-3)' }}>{it.sub}</div>
|
| 1002 |
+
</div>
|
| 1003 |
+
<span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 9.5, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>{it.kind}</span>
|
| 1004 |
+
</button>
|
| 1005 |
+
))}
|
| 1006 |
+
</div>
|
| 1007 |
+
<div style={{ padding: '8px 14px', borderTop: '1px solid var(--canvas-border)', display: 'flex', gap: 14, fontSize: 10.5, color: 'var(--canvas-text-3)', fontFamily: 'var(--canvas-mono)' }}>
|
| 1008 |
+
<span>↑↓ navigate</span><span>↵ jump</span><span>esc close</span>
|
| 1009 |
+
</div>
|
| 1010 |
+
</div>
|
| 1011 |
+
);
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
// ---------- Command palette ----------
|
| 1015 |
export function CommandPaletteModal({ data, onClose }) {
|
| 1016 |
const [q, setQ] = useState('');
|
phd-advisor-frontend/src/components/canvas/CanvasWidgets.js
CHANGED
|
@@ -5,12 +5,64 @@ import Icon from './CanvasIcon';
|
|
| 5 |
|
| 6 |
const fireToast = (msg, kind = 'success') =>
|
| 7 |
window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
// ===== Bibliography =====
|
| 10 |
export function BibliographyWidget({ state, setState, openModal }) {
|
| 11 |
const formats = ['APA', 'MLA', 'Chicago', 'BibTeX'];
|
| 12 |
const fmt = state.format || 'APA';
|
| 13 |
const [sortBy, setSortBy] = useState('year');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
const setFmt = (f) => setState({ ...state, format: f });
|
| 15 |
|
| 16 |
const formatEntry = (e) => {
|
|
@@ -46,7 +98,19 @@ export function BibliographyWidget({ state, setState, openModal }) {
|
|
| 46 |
});
|
| 47 |
|
| 48 |
return (
|
| 49 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
|
| 51 |
<div className="format-tabs">
|
| 52 |
{formats.map(f => (
|
|
@@ -78,10 +142,12 @@ export function BibliographyWidget({ state, setState, openModal }) {
|
|
| 78 |
</div>
|
| 79 |
))}
|
| 80 |
{sorted.length === 0 && (
|
| 81 |
-
<div style={{ padding: 18, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 12 }}>
|
|
|
|
|
|
|
| 82 |
)}
|
| 83 |
</div>
|
| 84 |
-
</>
|
| 85 |
);
|
| 86 |
}
|
| 87 |
|
|
@@ -313,6 +379,7 @@ export function WritingWidget({ state, setState }) {
|
|
| 313 |
_sessionDelta: 0,
|
| 314 |
});
|
| 315 |
fireToast(`Saved · ${words} words in ${active.name}`);
|
|
|
|
| 316 |
};
|
| 317 |
|
| 318 |
const addChapter = () => {
|
|
@@ -606,11 +673,15 @@ export function ReadingQueueWidget({ state, setState, openModal }) {
|
|
| 606 |
<>
|
| 607 |
<div className="dl-list">
|
| 608 |
{state.map((p, i) => (
|
| 609 |
-
<div key={i} className="dl-row"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
<span style={{ width: 4, height: 28, borderRadius: 2, background: colors[p.priority], flexShrink: 0 }}/>
|
| 611 |
<div className="dl-info" onClick={() => edit(p, i)} style={{ cursor: 'pointer' }}>
|
| 612 |
<div className="dl-title" style={{ fontSize: 12 }}>{p.title}</div>
|
| 613 |
-
<div className="dl-sub">{p.priority} · ~{p.time}</div>
|
| 614 |
</div>
|
| 615 |
<button className="icon-btn" style={{width:24,height:24}} title="Mark read" onClick={() => remove(i, p)}>
|
| 616 |
<Icon name="check" size={13}/>
|
|
@@ -1100,6 +1171,257 @@ export function LatexWidget({ state, setState }) {
|
|
| 1100 |
);
|
| 1101 |
}
|
| 1102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1103 |
// ===== Stub =====
|
| 1104 |
export function StubWidget({ meta }) {
|
| 1105 |
return (
|
|
|
|
| 5 |
|
| 6 |
const fireToast = (msg, kind = 'success') =>
|
| 7 |
window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
|
| 8 |
+
const fireActivity = (source, msg) =>
|
| 9 |
+
window.dispatchEvent(new CustomEvent('canvas-activity', { detail: { source, msg } }));
|
| 10 |
+
|
| 11 |
+
// Cross-widget drag-drop helpers — one mime type, JSON payload tagged by `kind`.
|
| 12 |
+
const X_MIME = 'application/x-canvas-item';
|
| 13 |
+
const setDragPayload = (e, kind, payload) => {
|
| 14 |
+
e.dataTransfer.setData(X_MIME, JSON.stringify({ kind, payload }));
|
| 15 |
+
e.dataTransfer.effectAllowed = 'copy';
|
| 16 |
+
};
|
| 17 |
+
const readDragPayload = (e) => {
|
| 18 |
+
try { return JSON.parse(e.dataTransfer.getData(X_MIME)); } catch { return null; }
|
| 19 |
+
};
|
| 20 |
|
| 21 |
// ===== Bibliography =====
|
| 22 |
export function BibliographyWidget({ state, setState, openModal }) {
|
| 23 |
const formats = ['APA', 'MLA', 'Chicago', 'BibTeX'];
|
| 24 |
const fmt = state.format || 'APA';
|
| 25 |
const [sortBy, setSortBy] = useState('year');
|
| 26 |
+
const [dropOver, setDropOver] = useState(false);
|
| 27 |
+
|
| 28 |
+
const onDrop = async (e) => {
|
| 29 |
+
e.preventDefault();
|
| 30 |
+
setDropOver(false);
|
| 31 |
+
const data = readDragPayload(e);
|
| 32 |
+
if (!data || data.kind !== 'paper') return;
|
| 33 |
+
const p = data.payload;
|
| 34 |
+
// If we have a DOI, hit CrossRef and build a real citation; else stub from title.
|
| 35 |
+
const titleStr = p.title || 'Untitled';
|
| 36 |
+
let entry = {
|
| 37 |
+
key: 'cite' + Date.now(),
|
| 38 |
+
authors: 'Unknown',
|
| 39 |
+
title: titleStr.replace(/^.*?— /, ''),
|
| 40 |
+
journal: '',
|
| 41 |
+
year: new Date().getFullYear(),
|
| 42 |
+
cited: 0,
|
| 43 |
+
doi: p.doi || '',
|
| 44 |
+
};
|
| 45 |
+
if (p.doi) {
|
| 46 |
+
try {
|
| 47 |
+
const cleaned = p.doi.trim().replace(/^https?:\/\/(dx\.)?doi\.org\//, '');
|
| 48 |
+
const res = await fetch(`https://api.crossref.org/works/${encodeURIComponent(cleaned)}`);
|
| 49 |
+
if (res.ok) {
|
| 50 |
+
const json = await res.json();
|
| 51 |
+
const w = json.message;
|
| 52 |
+
entry.authors = (w.author || []).map(a => `${a.family || ''}, ${(a.given || '').charAt(0)}.`).join('; ') || entry.authors;
|
| 53 |
+
entry.title = (w.title && w.title[0]) || entry.title;
|
| 54 |
+
entry.journal = (w['container-title'] && w['container-title'][0]) || '';
|
| 55 |
+
entry.year = (w.issued && w.issued['date-parts'] && w.issued['date-parts'][0][0]) || entry.year;
|
| 56 |
+
const firstAuthor = (entry.authors.split(',')[0] || 'cite').toLowerCase().replace(/[^a-z]/g, '');
|
| 57 |
+
entry.key = firstAuthor + entry.year;
|
| 58 |
+
}
|
| 59 |
+
} catch { /* fall through with stub */ }
|
| 60 |
+
}
|
| 61 |
+
setState({ ...state, entries: [...state.entries, entry] });
|
| 62 |
+
fireToast(`@${entry.key} added from Reading Queue`);
|
| 63 |
+
fireActivity('Bibliography', `Added @${entry.key} (from Reading Queue)`);
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
const setFmt = (f) => setState({ ...state, format: f });
|
| 67 |
|
| 68 |
const formatEntry = (e) => {
|
|
|
|
| 98 |
});
|
| 99 |
|
| 100 |
return (
|
| 101 |
+
<div
|
| 102 |
+
onDragOver={(e) => { if (e.dataTransfer.types.includes(X_MIME)) { e.preventDefault(); setDropOver(true); } }}
|
| 103 |
+
onDragLeave={() => setDropOver(false)}
|
| 104 |
+
onDrop={onDrop}
|
| 105 |
+
style={{ display: 'flex', flexDirection: 'column', gap: 10, position: 'relative', flex: 1 }}
|
| 106 |
+
className={dropOver ? 'canvas-drop-active' : ''}
|
| 107 |
+
>
|
| 108 |
+
{dropOver && (
|
| 109 |
+
<div className="canvas-drop-overlay">
|
| 110 |
+
<Icon name="plus" size={18}/>
|
| 111 |
+
<span>Drop to add citation</span>
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
|
| 115 |
<div className="format-tabs">
|
| 116 |
{formats.map(f => (
|
|
|
|
| 142 |
</div>
|
| 143 |
))}
|
| 144 |
{sorted.length === 0 && (
|
| 145 |
+
<div style={{ padding: 18, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 12 }}>
|
| 146 |
+
No citations yet. Add one or drag a paper from the Reading Queue.
|
| 147 |
+
</div>
|
| 148 |
)}
|
| 149 |
</div>
|
| 150 |
+
</div>
|
| 151 |
);
|
| 152 |
}
|
| 153 |
|
|
|
|
| 379 |
_sessionDelta: 0,
|
| 380 |
});
|
| 381 |
fireToast(`Saved · ${words} words in ${active.name}`);
|
| 382 |
+
fireActivity('Writing', `Saved ${words} words to "${active.name}"`);
|
| 383 |
};
|
| 384 |
|
| 385 |
const addChapter = () => {
|
|
|
|
| 673 |
<>
|
| 674 |
<div className="dl-list">
|
| 675 |
{state.map((p, i) => (
|
| 676 |
+
<div key={i} className="dl-row"
|
| 677 |
+
draggable
|
| 678 |
+
onDragStart={(e) => setDragPayload(e, 'paper', p)}
|
| 679 |
+
title="Drag to Bibliography to add as citation"
|
| 680 |
+
style={{ padding: '7px 9px', position: 'relative', cursor: 'grab' }}>
|
| 681 |
<span style={{ width: 4, height: 28, borderRadius: 2, background: colors[p.priority], flexShrink: 0 }}/>
|
| 682 |
<div className="dl-info" onClick={() => edit(p, i)} style={{ cursor: 'pointer' }}>
|
| 683 |
<div className="dl-title" style={{ fontSize: 12 }}>{p.title}</div>
|
| 684 |
+
<div className="dl-sub">{p.priority} · ~{p.time}{p.doi ? ' · ' + p.doi : ''}</div>
|
| 685 |
</div>
|
| 686 |
<button className="icon-btn" style={{width:24,height:24}} title="Mark read" onClick={() => remove(i, p)}>
|
| 687 |
<Icon name="check" size={13}/>
|
|
|
|
| 1171 |
);
|
| 1172 |
}
|
| 1173 |
|
| 1174 |
+
// ===== Calendar — month grid with deadlines (red) and writing days (green) =====
|
| 1175 |
+
export function CalendarWidget({ state, setState, allStates = {} }) {
|
| 1176 |
+
const monthStr = state.viewMonth || new Date().toISOString().slice(0, 7);
|
| 1177 |
+
const [year, month] = monthStr.split('-').map(Number);
|
| 1178 |
+
const first = new Date(year, month - 1, 1);
|
| 1179 |
+
const startWeekday = first.getDay(); // Sunday-indexed
|
| 1180 |
+
const daysInMonth = new Date(year, month, 0).getDate();
|
| 1181 |
+
|
| 1182 |
+
// Source data from cross-widget state
|
| 1183 |
+
const deadlines = (allStates.deadlines || []).reduce((m, d) => {
|
| 1184 |
+
const k = d.date.slice(0, 10);
|
| 1185 |
+
(m[k] ||= []).push({ kind: 'deadline', ...d });
|
| 1186 |
+
return m;
|
| 1187 |
+
}, {});
|
| 1188 |
+
const writing = ((allStates.writing && allStates.writing.dailyTotals) || {});
|
| 1189 |
+
const kanbanCards = (allStates.kanban && allStates.kanban.cards) || [];
|
| 1190 |
+
|
| 1191 |
+
const cells = [];
|
| 1192 |
+
for (let i = 0; i < startWeekday; i++) cells.push(null);
|
| 1193 |
+
for (let d = 1; d <= daysInMonth; d++) {
|
| 1194 |
+
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
| 1195 |
+
cells.push({ d, dateStr });
|
| 1196 |
+
}
|
| 1197 |
+
while (cells.length % 7) cells.push(null);
|
| 1198 |
+
|
| 1199 |
+
const shiftMonth = (delta) => {
|
| 1200 |
+
const next = new Date(year, month - 1 + delta, 1);
|
| 1201 |
+
setState({ ...state, viewMonth: next.toISOString().slice(0, 7) });
|
| 1202 |
+
};
|
| 1203 |
+
const monthLabel = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
| 1204 |
+
const today = new Date().toISOString().slice(0, 10);
|
| 1205 |
+
|
| 1206 |
+
return (
|
| 1207 |
+
<>
|
| 1208 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
| 1209 |
+
<button className="icon-btn" onClick={() => shiftMonth(-1)}><Icon name="chevron" size={14} style={{ transform: 'rotate(180deg)' }}/></button>
|
| 1210 |
+
<div style={{ flex: 1, fontWeight: 600, fontSize: 13 }}>{monthLabel}</div>
|
| 1211 |
+
<button className="icon-btn" onClick={() => shiftMonth(1)}><Icon name="chevron" size={14}/></button>
|
| 1212 |
+
</div>
|
| 1213 |
+
<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)' }}>
|
| 1214 |
+
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d, i) => <div key={i}>{d}</div>)}
|
| 1215 |
+
</div>
|
| 1216 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3 }}>
|
| 1217 |
+
{cells.map((c, i) => {
|
| 1218 |
+
if (!c) return <div key={i} style={{ aspectRatio: '1' }}/>;
|
| 1219 |
+
const dls = deadlines[c.dateStr] || [];
|
| 1220 |
+
const words = writing[c.dateStr] || 0;
|
| 1221 |
+
const dueCards = kanbanCards.filter(card => (card.meta || '').includes(c.dateStr));
|
| 1222 |
+
const isToday = c.dateStr === today;
|
| 1223 |
+
return (
|
| 1224 |
+
<div key={i}
|
| 1225 |
+
title={[
|
| 1226 |
+
...dls.map(d => `Deadline: ${d.title}`),
|
| 1227 |
+
...(words ? [`${words} words written`] : []),
|
| 1228 |
+
...dueCards.map(d => `Task: ${d.title}`),
|
| 1229 |
+
].join('\n') || c.dateStr}
|
| 1230 |
+
style={{
|
| 1231 |
+
aspectRatio: '1',
|
| 1232 |
+
border: `1px solid ${isToday ? 'var(--canvas-accent)' : 'var(--canvas-border)'}`,
|
| 1233 |
+
background: isToday ? 'var(--canvas-accent-glow)' : 'var(--canvas-bg-2)',
|
| 1234 |
+
borderRadius: 4,
|
| 1235 |
+
padding: 3,
|
| 1236 |
+
display: 'flex',
|
| 1237 |
+
flexDirection: 'column',
|
| 1238 |
+
gap: 1,
|
| 1239 |
+
fontSize: 10,
|
| 1240 |
+
position: 'relative',
|
| 1241 |
+
}}
|
| 1242 |
+
>
|
| 1243 |
+
<span style={{ fontFamily: 'var(--canvas-mono)', color: isToday ? 'var(--canvas-accent)' : 'var(--canvas-text-2)', fontWeight: isToday ? 700 : 500 }}>
|
| 1244 |
+
{c.d}
|
| 1245 |
+
</span>
|
| 1246 |
+
<div style={{ display: 'flex', gap: 2, flexWrap: 'wrap', marginTop: 'auto' }}>
|
| 1247 |
+
{dls.length > 0 && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--canvas-danger)' }}/>}
|
| 1248 |
+
{words > 0 && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--canvas-ok)' }}/>}
|
| 1249 |
+
{dueCards.length > 0 && <span style={{ width: 5, height: 5, borderRadius: '50%', background: 'var(--canvas-warn)' }}/>}
|
| 1250 |
+
</div>
|
| 1251 |
+
</div>
|
| 1252 |
+
);
|
| 1253 |
+
})}
|
| 1254 |
+
</div>
|
| 1255 |
+
<div style={{ display: 'flex', gap: 12, fontSize: 10, color: 'var(--canvas-text-3)', flexWrap: 'wrap' }}>
|
| 1256 |
+
<span><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--canvas-danger)', marginRight: 4 }}/>Deadlines</span>
|
| 1257 |
+
<span><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--canvas-ok)', marginRight: 4 }}/>Writing</span>
|
| 1258 |
+
<span><span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--canvas-warn)', marginRight: 4 }}/>Tasks</span>
|
| 1259 |
+
</div>
|
| 1260 |
+
</>
|
| 1261 |
+
);
|
| 1262 |
+
}
|
| 1263 |
+
|
| 1264 |
+
// ===== Activity Feed — surfaces recent edits across all widgets =====
|
| 1265 |
+
// Activity entries are written by other widgets via window.dispatchEvent('canvas-activity').
|
| 1266 |
+
// We persist a rolling buffer of the last 100 events in this widget's own state.
|
| 1267 |
+
export function ActivityWidget({ state, setState }) {
|
| 1268 |
+
const events = state.events || [];
|
| 1269 |
+
useEffect(() => {
|
| 1270 |
+
const handler = (e) => {
|
| 1271 |
+
const entry = { id: 'a' + Date.now() + Math.random(), at: Date.now(), ...e.detail };
|
| 1272 |
+
setState(prev => {
|
| 1273 |
+
const next = { ...(prev || {}), events: [entry, ...((prev && prev.events) || [])].slice(0, 100) };
|
| 1274 |
+
return next;
|
| 1275 |
+
});
|
| 1276 |
+
};
|
| 1277 |
+
window.addEventListener('canvas-activity', handler);
|
| 1278 |
+
return () => window.removeEventListener('canvas-activity', handler);
|
| 1279 |
+
// setState ref is stable
|
| 1280 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 1281 |
+
}, []);
|
| 1282 |
+
|
| 1283 |
+
const fmt = (t) => {
|
| 1284 |
+
const d = Date.now() - t;
|
| 1285 |
+
if (d < 60_000) return 'just now';
|
| 1286 |
+
if (d < 3600_000) return Math.floor(d / 60_000) + 'm ago';
|
| 1287 |
+
if (d < 86400_000) return Math.floor(d / 3600_000) + 'h ago';
|
| 1288 |
+
return Math.floor(d / 86400_000) + 'd ago';
|
| 1289 |
+
};
|
| 1290 |
+
|
| 1291 |
+
return (
|
| 1292 |
+
<>
|
| 1293 |
+
<div className="note-list">
|
| 1294 |
+
{events.length === 0 && (
|
| 1295 |
+
<div style={{ padding: 18, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 12 }}>
|
| 1296 |
+
No activity yet. Edits across widgets will show up here.
|
| 1297 |
+
</div>
|
| 1298 |
+
)}
|
| 1299 |
+
{events.map(e => (
|
| 1300 |
+
<div key={e.id} className="note-row" style={{ padding: '7px 9px' }}>
|
| 1301 |
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
| 1302 |
+
<span className="tag-pill">{e.source}</span>
|
| 1303 |
+
<span style={{ fontSize: 12, flex: 1 }}>{e.msg}</span>
|
| 1304 |
+
<span style={{ fontFamily: 'var(--canvas-mono)', fontSize: 10, color: 'var(--canvas-text-3)' }}>{fmt(e.at)}</span>
|
| 1305 |
+
</div>
|
| 1306 |
+
</div>
|
| 1307 |
+
))}
|
| 1308 |
+
</div>
|
| 1309 |
+
{events.length > 0 && (
|
| 1310 |
+
<button className="add-tiny" onClick={() => setState({ ...state, events: [] })}>Clear feed</button>
|
| 1311 |
+
)}
|
| 1312 |
+
</>
|
| 1313 |
+
);
|
| 1314 |
+
}
|
| 1315 |
+
|
| 1316 |
+
// ===== Daily Documenter — date-stamped journal with stub AI weekly summary =====
|
| 1317 |
+
export function DocumenterWidget({ state, setState }) {
|
| 1318 |
+
const entries = state.entries || [];
|
| 1319 |
+
const [draft, setDraft] = useState('');
|
| 1320 |
+
const today = new Date().toISOString().slice(0, 10);
|
| 1321 |
+
const [generating, setGenerating] = useState(false);
|
| 1322 |
+
|
| 1323 |
+
const append = () => {
|
| 1324 |
+
if (!draft.trim()) return;
|
| 1325 |
+
const entry = { id: 'doc' + Date.now(), date: today, text: draft.trim(), at: Date.now() };
|
| 1326 |
+
setState({ ...state, entries: [entry, ...entries] });
|
| 1327 |
+
setDraft('');
|
| 1328 |
+
window.dispatchEvent(new CustomEvent('canvas-activity', {
|
| 1329 |
+
detail: { source: 'Documenter', msg: `Logged: ${draft.trim().slice(0, 50)}…` },
|
| 1330 |
+
}));
|
| 1331 |
+
fireToast('Entry saved');
|
| 1332 |
+
};
|
| 1333 |
+
|
| 1334 |
+
const remove = (id) => setState({ ...state, entries: entries.filter(e => e.id !== id) });
|
| 1335 |
+
|
| 1336 |
+
// TODO(LLM): wire to /api/summarize-week with last 7 days of entries.
|
| 1337 |
+
// Backend call shape (commented because endpoint isn't ready):
|
| 1338 |
+
// const generateSummary = async () => {
|
| 1339 |
+
// const last7 = entries.filter(e => Date.now() - e.at < 7*86400_000);
|
| 1340 |
+
// const res = await fetch(`${process.env.REACT_APP_API_URL}/api/canvas/summarize`, {
|
| 1341 |
+
// method: 'POST',
|
| 1342 |
+
// body: JSON.stringify({ entries: last7 }),
|
| 1343 |
+
// });
|
| 1344 |
+
// const { summary } = await res.json();
|
| 1345 |
+
// setState({ ...state, lastSummary: { at: Date.now(), text: summary } });
|
| 1346 |
+
// };
|
| 1347 |
+
const generateSummary = () => {
|
| 1348 |
+
// Stub: echo back a structured summary built from the local entries so the UI
|
| 1349 |
+
// shape is real even though no LLM is hooked up yet.
|
| 1350 |
+
setGenerating(true);
|
| 1351 |
+
setTimeout(() => {
|
| 1352 |
+
const last7 = entries.filter(e => Date.now() - e.at < 7 * 86400_000);
|
| 1353 |
+
const text = last7.length === 0
|
| 1354 |
+
? 'No entries in the last 7 days.'
|
| 1355 |
+
: `**This week** · ${last7.length} entries logged.\n\n` +
|
| 1356 |
+
'_LLM summary will replace this once the backend endpoint is wired._\n\n' +
|
| 1357 |
+
last7.slice(0, 3).map(e => `- ${e.date}: ${e.text.slice(0, 80)}${e.text.length > 80 ? '…' : ''}`).join('\n');
|
| 1358 |
+
setState({ ...state, lastSummary: { at: Date.now(), text } });
|
| 1359 |
+
setGenerating(false);
|
| 1360 |
+
fireToast('Weekly summary generated (stub)');
|
| 1361 |
+
}, 600);
|
| 1362 |
+
};
|
| 1363 |
+
|
| 1364 |
+
// Group entries by date
|
| 1365 |
+
const grouped = entries.reduce((m, e) => { (m[e.date] ||= []).push(e); return m; }, {});
|
| 1366 |
+
const dates = Object.keys(grouped).sort().reverse();
|
| 1367 |
+
|
| 1368 |
+
return (
|
| 1369 |
+
<>
|
| 1370 |
+
<div className="form-grid">
|
| 1371 |
+
<textarea
|
| 1372 |
+
className="textarea"
|
| 1373 |
+
placeholder={`What did you do today? (${today})`}
|
| 1374 |
+
rows={3}
|
| 1375 |
+
style={{ minHeight: 50, fontSize: 12.5 }}
|
| 1376 |
+
value={draft}
|
| 1377 |
+
onChange={e => setDraft(e.target.value)}
|
| 1378 |
+
onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) append(); }}
|
| 1379 |
+
/>
|
| 1380 |
+
<div style={{ display: 'flex', gap: 6 }}>
|
| 1381 |
+
<button className="btn btn-primary" onClick={append} disabled={!draft.trim()}>
|
| 1382 |
+
<Icon name="plus" size={13}/>Log entry
|
| 1383 |
+
</button>
|
| 1384 |
+
<button className="btn" onClick={generateSummary} disabled={entries.length === 0 || generating}>
|
| 1385 |
+
{generating ? <><div className="spinner"/>Summarizing</> : <><Icon name="sparkles" size={13}/>Weekly summary</>}
|
| 1386 |
+
</button>
|
| 1387 |
+
</div>
|
| 1388 |
+
</div>
|
| 1389 |
+
{state.lastSummary && (
|
| 1390 |
+
<div className="review" style={{ borderLeftColor: 'var(--canvas-accent)' }}>
|
| 1391 |
+
<span className="review-tag" style={{ color: 'var(--canvas-accent)' }}>
|
| 1392 |
+
Generated {new Date(state.lastSummary.at).toLocaleString()} · stub
|
| 1393 |
+
</span>
|
| 1394 |
+
<div className="canvas-md">
|
| 1395 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{state.lastSummary.text}</ReactMarkdown>
|
| 1396 |
+
</div>
|
| 1397 |
+
</div>
|
| 1398 |
+
)}
|
| 1399 |
+
<div className="note-list" style={{ maxHeight: 260 }}>
|
| 1400 |
+
{dates.map(date => (
|
| 1401 |
+
<div key={date}>
|
| 1402 |
+
<div style={{ fontSize: 10, color: 'var(--canvas-text-4)', textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600, fontFamily: 'var(--canvas-mono)', padding: '6px 2px 2px' }}>
|
| 1403 |
+
{new Date(date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}
|
| 1404 |
+
</div>
|
| 1405 |
+
{grouped[date].map(e => (
|
| 1406 |
+
<div key={e.id} className="note-row" style={{ position: 'relative' }}>
|
| 1407 |
+
<div className="note-text">{e.text}</div>
|
| 1408 |
+
<div className="row-actions">
|
| 1409 |
+
<button className="icon-btn" onClick={() => remove(e.id)}><Icon name="trash" size={11}/></button>
|
| 1410 |
+
</div>
|
| 1411 |
+
</div>
|
| 1412 |
+
))}
|
| 1413 |
+
</div>
|
| 1414 |
+
))}
|
| 1415 |
+
{entries.length === 0 && (
|
| 1416 |
+
<div style={{ padding: 18, textAlign: 'center', color: 'var(--canvas-text-3)', fontSize: 12 }}>
|
| 1417 |
+
No entries yet. Drop a quick line about today.
|
| 1418 |
+
</div>
|
| 1419 |
+
)}
|
| 1420 |
+
</div>
|
| 1421 |
+
</>
|
| 1422 |
+
);
|
| 1423 |
+
}
|
| 1424 |
+
|
| 1425 |
// ===== Stub =====
|
| 1426 |
export function StubWidget({ meta }) {
|
| 1427 |
return (
|
phd-advisor-frontend/src/components/canvas/canvasData.js
CHANGED
|
@@ -100,6 +100,9 @@ export const WIDGET_CATALOG = [
|
|
| 100 |
{ type: 'gantt', name: 'Milestone Timeline', desc: 'Proposal → IRB → defense', icon: 'flag', cat: 'project', defaultSize: 'L', stub: true },
|
| 101 |
{ type: 'meeting-log', name: 'Meeting Log', desc: 'Per-stakeholder, last contact, actions', icon: 'message', cat: 'project', defaultSize: 'M' },
|
| 102 |
{ type: 'goals', name: 'Goals / OKRs', desc: 'Quarterly milestones with progress sliders', icon: 'bullseye', cat: 'project', defaultSize: 'M' },
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
{ type: 'mood', name: 'Mood / Burnout Check-in', desc: 'Daily slider, trend graph', icon: 'smile', cat: 'wellness', defaultSize: 'S', stub: true },
|
| 105 |
{ type: 'sleep', name: 'Sleep & Energy', desc: 'Correlate with productive days', icon: 'heart', cat: 'wellness', defaultSize: 'S', stub: true },
|
|
@@ -137,9 +140,70 @@ export const CATEGORIES = [
|
|
| 137 |
{ id: 'critic', label: 'Anti-yes-man', critic: true },
|
| 138 |
];
|
| 139 |
|
| 140 |
-
// Workspace starts empty — users add widgets from the palette.
|
| 141 |
export const DEFAULT_LAYOUT = [];
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
// Initial state when a widget is first added — minimal scaffolding, no demo content.
|
| 144 |
export const EMPTY_STATE = {
|
| 145 |
bibliography: { format: 'APA', entries: [] },
|
|
@@ -178,5 +242,8 @@ export const EMPTY_STATE = {
|
|
| 178 |
outline: { items: [], expanded: {} },
|
| 179 |
highlights: { items: [] },
|
| 180 |
latex: { source: '', displayMode: true },
|
|
|
|
|
|
|
|
|
|
| 181 |
};
|
| 182 |
|
|
|
|
| 100 |
{ type: 'gantt', name: 'Milestone Timeline', desc: 'Proposal → IRB → defense', icon: 'flag', cat: 'project', defaultSize: 'L', stub: true },
|
| 101 |
{ type: 'meeting-log', name: 'Meeting Log', desc: 'Per-stakeholder, last contact, actions', icon: 'message', cat: 'project', defaultSize: 'M' },
|
| 102 |
{ type: 'goals', name: 'Goals / OKRs', desc: 'Quarterly milestones with progress sliders', icon: 'bullseye', cat: 'project', defaultSize: 'M' },
|
| 103 |
+
{ type: 'calendar', name: 'Calendar', desc: 'Month grid with deadlines and writing days', icon: 'calendar', cat: 'project', defaultSize: 'M', enhanced: true },
|
| 104 |
+
{ type: 'activity', name: 'Activity Feed', desc: 'Chronological log of edits across widgets', icon: 'graph', cat: 'project', defaultSize: 'M', enhanced: true },
|
| 105 |
+
{ type: 'documenter', name: 'Daily Documenter', desc: 'Date-stamped journal · AI weekly summary (LLM stub)', icon: 'pencil', cat: 'project', defaultSize: 'M', enhanced: true },
|
| 106 |
|
| 107 |
{ type: 'mood', name: 'Mood / Burnout Check-in', desc: 'Daily slider, trend graph', icon: 'smile', cat: 'wellness', defaultSize: 'S', stub: true },
|
| 108 |
{ type: 'sleep', name: 'Sleep & Energy', desc: 'Correlate with productive days', icon: 'heart', cat: 'wellness', defaultSize: 'S', stub: true },
|
|
|
|
| 140 |
{ id: 'critic', label: 'Anti-yes-man', critic: true },
|
| 141 |
];
|
| 142 |
|
| 143 |
+
// Workspace starts empty — users add widgets from the palette or pick a preset.
|
| 144 |
export const DEFAULT_LAYOUT = [];
|
| 145 |
|
| 146 |
+
// Curated starter layouts. Each preset assigns its own widget IDs so reseeding
|
| 147 |
+
// won't collide with manually-added widgets.
|
| 148 |
+
const presetIds = (types) => types.map((t, i) => ({ id: `pre-${t.type}-${i}`, ...t }));
|
| 149 |
+
export const WORKSPACE_PRESETS = [
|
| 150 |
+
{
|
| 151 |
+
id: 'day1-phd',
|
| 152 |
+
name: 'Day-1 PhD',
|
| 153 |
+
desc: 'Get oriented: reading queue, bibliography, notes, deadlines, kanban, pomodoro.',
|
| 154 |
+
icon: 'sparkles',
|
| 155 |
+
layout: presetIds([
|
| 156 |
+
{ type: 'reading-queue', size: 'M' },
|
| 157 |
+
{ type: 'bibliography', size: 'M' },
|
| 158 |
+
{ type: 'notes', size: 'M' },
|
| 159 |
+
{ type: 'deadlines', size: 'S' },
|
| 160 |
+
{ type: 'pomodoro', size: 'S' },
|
| 161 |
+
{ type: 'kanban', size: 'L' },
|
| 162 |
+
]),
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
id: 'writing-sprint',
|
| 166 |
+
name: 'Writing Sprint',
|
| 167 |
+
desc: 'Focus mode for drafting: writing pad, outline, LaTeX, highlights, pomodoro.',
|
| 168 |
+
icon: 'pencil',
|
| 169 |
+
layout: presetIds([
|
| 170 |
+
{ type: 'writing', size: 'M' },
|
| 171 |
+
{ type: 'outline', size: 'M' },
|
| 172 |
+
{ type: 'pomodoro', size: 'S' },
|
| 173 |
+
{ type: 'latex', size: 'M' },
|
| 174 |
+
{ type: 'highlights', size: 'M' },
|
| 175 |
+
{ type: 'bibliography', size: 'M' },
|
| 176 |
+
]),
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
id: 'quals-prep',
|
| 180 |
+
name: 'Quals Prep',
|
| 181 |
+
desc: 'Lit-review heavy: bibliography, reading queue, notes, highlights, kanban.',
|
| 182 |
+
icon: 'book',
|
| 183 |
+
layout: presetIds([
|
| 184 |
+
{ type: 'bibliography', size: 'L' },
|
| 185 |
+
{ type: 'reading-queue', size: 'M' },
|
| 186 |
+
{ type: 'notes', size: 'M' },
|
| 187 |
+
{ type: 'highlights', size: 'M' },
|
| 188 |
+
{ type: 'kanban', size: 'M' },
|
| 189 |
+
]),
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
id: 'defense-mode',
|
| 193 |
+
name: 'Defense Mode',
|
| 194 |
+
desc: 'Final stretch: writing, outline, anti-yes-man critics, deadlines.',
|
| 195 |
+
icon: 'gavel',
|
| 196 |
+
layout: presetIds([
|
| 197 |
+
{ type: 'writing', size: 'M' },
|
| 198 |
+
{ type: 'outline', size: 'M' },
|
| 199 |
+
{ type: 'reviewer-2', size: 'M', critic: true },
|
| 200 |
+
{ type: 'devils-advocate', size: 'M', critic: true },
|
| 201 |
+
{ type: 'scope-realism', size: 'M', critic: true },
|
| 202 |
+
{ type: 'deadlines', size: 'S' },
|
| 203 |
+
]),
|
| 204 |
+
},
|
| 205 |
+
];
|
| 206 |
+
|
| 207 |
// Initial state when a widget is first added — minimal scaffolding, no demo content.
|
| 208 |
export const EMPTY_STATE = {
|
| 209 |
bibliography: { format: 'APA', entries: [] },
|
|
|
|
| 242 |
outline: { items: [], expanded: {} },
|
| 243 |
highlights: { items: [] },
|
| 244 |
latex: { source: '', displayMode: true },
|
| 245 |
+
calendar: { viewMonth: new Date().toISOString().slice(0, 7) },
|
| 246 |
+
activity: {},
|
| 247 |
+
documenter: { entries: [], lastSummary: null },
|
| 248 |
};
|
| 249 |
|
phd-advisor-frontend/src/pages/CanvasPage.js
CHANGED
|
@@ -6,13 +6,14 @@ import Sidebar from '../components/Sidebar';
|
|
| 6 |
import AppHeader from '../components/AppHeader';
|
| 7 |
import Icon from '../components/canvas/CanvasIcon';
|
| 8 |
import {
|
| 9 |
-
INSIGHTS, WIDGET_CATALOG, DEFAULT_LAYOUT, EMPTY_STATE,
|
| 10 |
} from '../components/canvas/canvasData';
|
| 11 |
import {
|
| 12 |
BibliographyWidget, KanbanWidget, PomodoroWidget, WritingWidget,
|
| 13 |
DeadlinesWidget, BudgetWidget, ReadingQueueWidget, NotesWidget,
|
| 14 |
HabitsWidget, GoalsWidget, MeetingsWidget,
|
| 15 |
OutlineWidget, HighlightsWidget, LatexWidget,
|
|
|
|
| 16 |
StubWidget,
|
| 17 |
} from '../components/canvas/CanvasWidgets';
|
| 18 |
import {
|
|
@@ -23,17 +24,18 @@ import {
|
|
| 23 |
AddCitationModal, AddTaskModal, AddDeadlineModal, LogWordsModal,
|
| 24 |
ConfirmRemoveModal, ReadingPaperModal, BudgetItemModal,
|
| 25 |
NoteModal, HabitModal, GoalModal, MeetingModal,
|
| 26 |
-
PaletteModal, CommandPaletteModal,
|
| 27 |
} from '../components/canvas/CanvasModals';
|
| 28 |
import CanvasWelcomeTour from '../components/canvas/CanvasWelcomeTour';
|
|
|
|
| 29 |
import '../styles/CanvasPage.css';
|
| 30 |
|
| 31 |
const LAYOUT_KEY = 'canvas-layout-v2';
|
| 32 |
const STATES_KEY = 'canvas-states-v2';
|
| 33 |
const VIEW_KEY = 'canvas-view-v2';
|
| 34 |
|
| 35 |
-
function renderWidget(type, state, setState, openModal) {
|
| 36 |
-
const props = { state, setState, openModal };
|
| 37 |
switch (type) {
|
| 38 |
case 'bibliography': return <BibliographyWidget {...props}/>;
|
| 39 |
case 'kanban': return <KanbanWidget {...props}/>;
|
|
@@ -52,6 +54,9 @@ function renderWidget(type, state, setState, openModal) {
|
|
| 52 |
case 'outline': return <OutlineWidget {...props}/>;
|
| 53 |
case 'highlights': return <HighlightsWidget {...props}/>;
|
| 54 |
case 'latex': return <LatexWidget {...props}/>;
|
|
|
|
|
|
|
|
|
|
| 55 |
default: {
|
| 56 |
const meta = WIDGET_CATALOG.find(w => w.type === type);
|
| 57 |
return <StubWidget meta={meta}/>;
|
|
@@ -59,7 +64,7 @@ function renderWidget(type, state, setState, openModal) {
|
|
| 59 |
}
|
| 60 |
}
|
| 61 |
|
| 62 |
-
function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver, onDragEnd, onDrop, state, setState, onRemove, onResize, openModal }) {
|
| 63 |
const meta = WIDGET_CATALOG.find(w => w.type === widget.type);
|
| 64 |
if (!meta) return null;
|
| 65 |
const sizes = ['S', 'M', 'L'];
|
|
@@ -69,6 +74,7 @@ function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver,
|
|
| 69 |
<div
|
| 70 |
className={`widget size-${widget.size} ${meta.critic ? 'critic' : ''} ${isDragging ? 'dragging' : ''} ${isDragOver ? 'drag-over' : ''}`}
|
| 71 |
data-widget-id={widget.id}
|
|
|
|
| 72 |
onDragOver={(e) => { e.preventDefault(); onDragOver(widget.id); }}
|
| 73 |
onDrop={(e) => { e.preventDefault(); onDrop(widget.id); }}
|
| 74 |
>
|
|
@@ -88,13 +94,13 @@ function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver,
|
|
| 88 |
</div>
|
| 89 |
</div>
|
| 90 |
<div className="widget-body">
|
| 91 |
-
{renderWidget(widget.type, state, setState, openModal)}
|
| 92 |
</div>
|
| 93 |
</div>
|
| 94 |
);
|
| 95 |
}
|
| 96 |
|
| 97 |
-
function InsightsView() {
|
| 98 |
const [pinned, setPinned] = useState(new Set(INSIGHTS.filter(i => i.pinned).map(i => i.id)));
|
| 99 |
const togglePin = (id) => {
|
| 100 |
const n = new Set(pinned);
|
|
@@ -102,6 +108,26 @@ function InsightsView() {
|
|
| 102 |
setPinned(n);
|
| 103 |
};
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
return (
|
| 106 |
<>
|
| 107 |
<div className="page-header">
|
|
@@ -133,9 +159,11 @@ function InsightsView() {
|
|
| 133 |
</ul>
|
| 134 |
</div>
|
| 135 |
<div className="insight-actions">
|
| 136 |
-
|
| 137 |
-
<button className="chip"><Icon name="
|
| 138 |
-
<button className="chip"><Icon name="
|
|
|
|
|
|
|
| 139 |
<button className="chip"><Icon name="expand" size={11}/>Expand</button>
|
| 140 |
<button className={`chip ${pinned.has(ins.id) ? 'pinned' : ''}`} onClick={() => togglePin(ins.id)}>
|
| 141 |
<Icon name="pin" size={11}/>{pinned.has(ins.id) ? 'Pinned' : 'Pin'}
|
|
@@ -148,6 +176,29 @@ function InsightsView() {
|
|
| 148 |
);
|
| 149 |
}
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetStates }) {
|
| 152 |
const [dragId, setDragId] = useState(null);
|
| 153 |
const [dragOverId, setDragOverId] = useState(null);
|
|
@@ -166,8 +217,11 @@ function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetSt
|
|
| 166 |
onDragEnd();
|
| 167 |
};
|
| 168 |
|
| 169 |
-
const setWState = (type) => (
|
| 170 |
-
setWidgetStates(s => ({
|
|
|
|
|
|
|
|
|
|
| 171 |
};
|
| 172 |
|
| 173 |
const removeWidget = (id, label) => {
|
|
@@ -187,11 +241,23 @@ function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetSt
|
|
| 187 |
const id = 'w-' + Date.now();
|
| 188 |
setLayout(l => [...l, { id, type: meta.type, size: meta.defaultSize, critic: meta.critic }]);
|
| 189 |
if (EMPTY_STATE[meta.type]) {
|
| 190 |
-
// Always reset to a fresh empty state when a widget is added (even if previously removed)
|
| 191 |
setWidgetStates(s => ({ ...s, [meta.type]: JSON.parse(JSON.stringify(EMPTY_STATE[meta.type])) }));
|
| 192 |
}
|
| 193 |
};
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
const reset = () => {
|
| 196 |
if (!window.confirm('Reset workspace? All widgets and content will be cleared.')) return;
|
| 197 |
setLayout([]);
|
|
@@ -214,12 +280,14 @@ function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetSt
|
|
| 214 |
})}><Icon name="plus" size={13}/>Add widget</button>
|
| 215 |
</div>
|
| 216 |
</div>
|
|
|
|
|
|
|
|
|
|
| 217 |
<div className="workspace">
|
| 218 |
{layout.length === 0 && (
|
| 219 |
<div className="empty-cell">
|
| 220 |
-
<Icon name="layout" size={
|
| 221 |
-
<div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>
|
| 222 |
-
<div>Add widgets from the palette to start composing your canvas.</div>
|
| 223 |
<button className="btn btn-primary" onClick={() => openModal('palette', { layout, onAdd: addWidget })} style={{ marginTop: 6 }}>
|
| 224 |
<Icon name="plus" size={13}/>Add your first widget
|
| 225 |
</button>
|
|
@@ -240,6 +308,7 @@ function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetSt
|
|
| 240 |
onRemove={removeWidget}
|
| 241 |
onResize={resizeWidget}
|
| 242 |
openModal={openModal}
|
|
|
|
| 243 |
/>
|
| 244 |
))}
|
| 245 |
</div>
|
|
@@ -268,6 +337,7 @@ function ModalRouter({ modal, onClose }) {
|
|
| 268 |
case 'goal': content = <GoalModal data={modal.data} onClose={onClose}/>; break;
|
| 269 |
case 'meeting': content = <MeetingModal data={modal.data} onClose={onClose}/>; break;
|
| 270 |
case 'command': content = <CommandPaletteModal data={modal.data} onClose={onClose}/>; break;
|
|
|
|
| 271 |
default: return null;
|
| 272 |
}
|
| 273 |
return <div className="canvas-modal-backdrop" onClick={handleBackdropClick}>{content}</div>;
|
|
@@ -358,7 +428,11 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
|
|
| 358 |
});
|
| 359 |
}, [openModal, layout, toggleTheme, exportWorkspace]);
|
| 360 |
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
useEffect(() => {
|
| 363 |
const k = (e) => {
|
| 364 |
if (e.key === 'Escape') closeModal();
|
|
@@ -366,10 +440,14 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
|
|
| 366 |
e.preventDefault();
|
| 367 |
openCommandPalette();
|
| 368 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
};
|
| 370 |
window.addEventListener('keydown', k);
|
| 371 |
return () => window.removeEventListener('keydown', k);
|
| 372 |
-
}, [closeModal, openCommandPalette]);
|
| 373 |
|
| 374 |
const canvasSidebarItems = layout.map(w => {
|
| 375 |
const meta = WIDGET_CATALOG.find(m => m.type === w.type);
|
|
@@ -406,7 +484,7 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
|
|
| 406 |
<div className={`canvas-main-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
| 407 |
<div className="canvas-app-shell">
|
| 408 |
<AppHeader
|
| 409 |
-
currentPage=
|
| 410 |
onNavigateToHome={onNavigateToHome}
|
| 411 |
onNavigateToChat={onNavigateToChat}
|
| 412 |
onNavigateToCanvas={(v) => setView(v || 'workspace')}
|
|
@@ -415,14 +493,17 @@ const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSig
|
|
| 415 |
<button className="icon-btn" onClick={() => setTourForceShow(n => n + 1)} title="Show tour">
|
| 416 |
<HelpCircle size={18}/>
|
| 417 |
</button>
|
| 418 |
-
<button className="icon-btn" onClick={
|
| 419 |
<Icon name="search" size={16}/>
|
| 420 |
</button>
|
|
|
|
|
|
|
|
|
|
| 421 |
</AppHeader>
|
| 422 |
<div className="canvas-content">
|
| 423 |
-
{view === 'insights'
|
| 424 |
-
|
| 425 |
-
|
| 426 |
</div>
|
| 427 |
</div>
|
| 428 |
</div>
|
|
|
|
| 6 |
import AppHeader from '../components/AppHeader';
|
| 7 |
import Icon from '../components/canvas/CanvasIcon';
|
| 8 |
import {
|
| 9 |
+
INSIGHTS, WIDGET_CATALOG, DEFAULT_LAYOUT, EMPTY_STATE, WORKSPACE_PRESETS,
|
| 10 |
} from '../components/canvas/canvasData';
|
| 11 |
import {
|
| 12 |
BibliographyWidget, KanbanWidget, PomodoroWidget, WritingWidget,
|
| 13 |
DeadlinesWidget, BudgetWidget, ReadingQueueWidget, NotesWidget,
|
| 14 |
HabitsWidget, GoalsWidget, MeetingsWidget,
|
| 15 |
OutlineWidget, HighlightsWidget, LatexWidget,
|
| 16 |
+
CalendarWidget, DocumenterWidget, ActivityWidget,
|
| 17 |
StubWidget,
|
| 18 |
} from '../components/canvas/CanvasWidgets';
|
| 19 |
import {
|
|
|
|
| 24 |
AddCitationModal, AddTaskModal, AddDeadlineModal, LogWordsModal,
|
| 25 |
ConfirmRemoveModal, ReadingPaperModal, BudgetItemModal,
|
| 26 |
NoteModal, HabitModal, GoalModal, MeetingModal,
|
| 27 |
+
PaletteModal, CommandPaletteModal, GlobalSearchModal,
|
| 28 |
} from '../components/canvas/CanvasModals';
|
| 29 |
import CanvasWelcomeTour from '../components/canvas/CanvasWelcomeTour';
|
| 30 |
+
import DeliverablesView from '../components/canvas/CanvasDeliverables';
|
| 31 |
import '../styles/CanvasPage.css';
|
| 32 |
|
| 33 |
const LAYOUT_KEY = 'canvas-layout-v2';
|
| 34 |
const STATES_KEY = 'canvas-states-v2';
|
| 35 |
const VIEW_KEY = 'canvas-view-v2';
|
| 36 |
|
| 37 |
+
function renderWidget(type, state, setState, openModal, allStates) {
|
| 38 |
+
const props = { state, setState, openModal, allStates };
|
| 39 |
switch (type) {
|
| 40 |
case 'bibliography': return <BibliographyWidget {...props}/>;
|
| 41 |
case 'kanban': return <KanbanWidget {...props}/>;
|
|
|
|
| 54 |
case 'outline': return <OutlineWidget {...props}/>;
|
| 55 |
case 'highlights': return <HighlightsWidget {...props}/>;
|
| 56 |
case 'latex': return <LatexWidget {...props}/>;
|
| 57 |
+
case 'calendar': return <CalendarWidget {...props}/>;
|
| 58 |
+
case 'documenter': return <DocumenterWidget {...props}/>;
|
| 59 |
+
case 'activity': return <ActivityWidget {...props}/>;
|
| 60 |
default: {
|
| 61 |
const meta = WIDGET_CATALOG.find(w => w.type === type);
|
| 62 |
return <StubWidget meta={meta}/>;
|
|
|
|
| 64 |
}
|
| 65 |
}
|
| 66 |
|
| 67 |
+
function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver, onDragEnd, onDrop, state, setState, onRemove, onResize, openModal, allStates }) {
|
| 68 |
const meta = WIDGET_CATALOG.find(w => w.type === widget.type);
|
| 69 |
if (!meta) return null;
|
| 70 |
const sizes = ['S', 'M', 'L'];
|
|
|
|
| 74 |
<div
|
| 75 |
className={`widget size-${widget.size} ${meta.critic ? 'critic' : ''} ${isDragging ? 'dragging' : ''} ${isDragOver ? 'drag-over' : ''}`}
|
| 76 |
data-widget-id={widget.id}
|
| 77 |
+
data-widget-type={widget.type}
|
| 78 |
onDragOver={(e) => { e.preventDefault(); onDragOver(widget.id); }}
|
| 79 |
onDrop={(e) => { e.preventDefault(); onDrop(widget.id); }}
|
| 80 |
>
|
|
|
|
| 94 |
</div>
|
| 95 |
</div>
|
| 96 |
<div className="widget-body">
|
| 97 |
+
{renderWidget(widget.type, state, setState, openModal, allStates)}
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
);
|
| 101 |
}
|
| 102 |
|
| 103 |
+
function InsightsView({ widgetStates, setWidgetStates }) {
|
| 104 |
const [pinned, setPinned] = useState(new Set(INSIGHTS.filter(i => i.pinned).map(i => i.id)));
|
| 105 |
const togglePin = (id) => {
|
| 106 |
const n = new Set(pinned);
|
|
|
|
| 108 |
setPinned(n);
|
| 109 |
};
|
| 110 |
|
| 111 |
+
// Strip HTML, take first 80 chars for the kanban card title.
|
| 112 |
+
const insightToTaskTitle = (ins) => {
|
| 113 |
+
const plain = (ins.bullets[0] || ins.summary || ins.title).replace(/<[^>]+>/g, '');
|
| 114 |
+
return plain.length > 80 ? plain.slice(0, 77) + '…' : plain;
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
const sendToKanban = (ins) => {
|
| 118 |
+
if (!setWidgetStates) return;
|
| 119 |
+
const kanban = widgetStates.kanban || EMPTY_STATE.kanban;
|
| 120 |
+
const card = {
|
| 121 |
+
id: 'k' + Date.now(),
|
| 122 |
+
col: 'todo',
|
| 123 |
+
title: insightToTaskTitle(ins),
|
| 124 |
+
priority: 'med',
|
| 125 |
+
meta: `from Insights · ${ins.title}`,
|
| 126 |
+
};
|
| 127 |
+
setWidgetStates(s => ({ ...s, kanban: { ...kanban, cards: [...kanban.cards, card] } }));
|
| 128 |
+
window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Sent to Kanban (To Do)', kind: 'success' } }));
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
return (
|
| 132 |
<>
|
| 133 |
<div className="page-header">
|
|
|
|
| 159 |
</ul>
|
| 160 |
</div>
|
| 161 |
<div className="insight-actions">
|
| 162 |
+
{/* TODO(LLM): wire "Ask follow-up" to chat endpoint with insight context */}
|
| 163 |
+
<button className="chip" disabled title="Needs LLM endpoint"><Icon name="message" size={11}/>Ask follow-up</button>
|
| 164 |
+
<button className="chip" onClick={() => sendToKanban(ins)}><Icon name="task" size={11}/>To task</button>
|
| 165 |
+
{/* TODO(LLM): wire "Cite" to search Bibliography for the source paper */}
|
| 166 |
+
<button className="chip" disabled title="Coming soon"><Icon name="cite" size={11}/>Cite</button>
|
| 167 |
<button className="chip"><Icon name="expand" size={11}/>Expand</button>
|
| 168 |
<button className={`chip ${pinned.has(ins.id) ? 'pinned' : ''}`} onClick={() => togglePin(ins.id)}>
|
| 169 |
<Icon name="pin" size={11}/>{pinned.has(ins.id) ? 'Pinned' : 'Pin'}
|
|
|
|
| 176 |
);
|
| 177 |
}
|
| 178 |
|
| 179 |
+
function PresetPicker({ onPick }) {
|
| 180 |
+
return (
|
| 181 |
+
<div className="canvas-presets">
|
| 182 |
+
<div className="canvas-presets-head">
|
| 183 |
+
<div className="canvas-presets-title">Start from a preset</div>
|
| 184 |
+
<div className="canvas-presets-sub">Or skip and add widgets one at a time.</div>
|
| 185 |
+
</div>
|
| 186 |
+
<div className="canvas-presets-grid">
|
| 187 |
+
{WORKSPACE_PRESETS.map(p => (
|
| 188 |
+
<button key={p.id} className="canvas-preset-card" onClick={() => onPick(p)}>
|
| 189 |
+
<div className="canvas-preset-icon"><Icon name={p.icon} size={18}/></div>
|
| 190 |
+
<div className="canvas-preset-content">
|
| 191 |
+
<div className="canvas-preset-name">{p.name}</div>
|
| 192 |
+
<div className="canvas-preset-desc">{p.desc}</div>
|
| 193 |
+
<div className="canvas-preset-meta">{p.layout.length} widgets</div>
|
| 194 |
+
</div>
|
| 195 |
+
</button>
|
| 196 |
+
))}
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
);
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetStates }) {
|
| 203 |
const [dragId, setDragId] = useState(null);
|
| 204 |
const [dragOverId, setDragOverId] = useState(null);
|
|
|
|
| 217 |
onDragEnd();
|
| 218 |
};
|
| 219 |
|
| 220 |
+
const setWState = (type) => (updater) => {
|
| 221 |
+
setWidgetStates(s => ({
|
| 222 |
+
...s,
|
| 223 |
+
[type]: typeof updater === 'function' ? updater(s[type]) : updater,
|
| 224 |
+
}));
|
| 225 |
};
|
| 226 |
|
| 227 |
const removeWidget = (id, label) => {
|
|
|
|
| 241 |
const id = 'w-' + Date.now();
|
| 242 |
setLayout(l => [...l, { id, type: meta.type, size: meta.defaultSize, critic: meta.critic }]);
|
| 243 |
if (EMPTY_STATE[meta.type]) {
|
|
|
|
| 244 |
setWidgetStates(s => ({ ...s, [meta.type]: JSON.parse(JSON.stringify(EMPTY_STATE[meta.type])) }));
|
| 245 |
}
|
| 246 |
};
|
| 247 |
|
| 248 |
+
const applyPreset = (preset) => {
|
| 249 |
+
setLayout(preset.layout.map(w => ({ ...w })));
|
| 250 |
+
// Seed empty state for any widget types not already present
|
| 251 |
+
const seeds = {};
|
| 252 |
+
preset.layout.forEach(w => {
|
| 253 |
+
if (!widgetStates[w.type] && EMPTY_STATE[w.type]) {
|
| 254 |
+
seeds[w.type] = JSON.parse(JSON.stringify(EMPTY_STATE[w.type]));
|
| 255 |
+
}
|
| 256 |
+
});
|
| 257 |
+
if (Object.keys(seeds).length) setWidgetStates(s => ({ ...s, ...seeds }));
|
| 258 |
+
window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: `${preset.name} preset loaded`, kind: 'success' } }));
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
const reset = () => {
|
| 262 |
if (!window.confirm('Reset workspace? All widgets and content will be cleared.')) return;
|
| 263 |
setLayout([]);
|
|
|
|
| 280 |
})}><Icon name="plus" size={13}/>Add widget</button>
|
| 281 |
</div>
|
| 282 |
</div>
|
| 283 |
+
{layout.length === 0 && (
|
| 284 |
+
<PresetPicker onPick={applyPreset}/>
|
| 285 |
+
)}
|
| 286 |
<div className="workspace">
|
| 287 |
{layout.length === 0 && (
|
| 288 |
<div className="empty-cell">
|
| 289 |
+
<Icon name="layout" size={28} style={{ color: 'var(--canvas-text-4)' }}/>
|
| 290 |
+
<div style={{ fontSize: 14, color: 'var(--canvas-text-2)', fontWeight: 500 }}>Or build from scratch</div>
|
|
|
|
| 291 |
<button className="btn btn-primary" onClick={() => openModal('palette', { layout, onAdd: addWidget })} style={{ marginTop: 6 }}>
|
| 292 |
<Icon name="plus" size={13}/>Add your first widget
|
| 293 |
</button>
|
|
|
|
| 308 |
onRemove={removeWidget}
|
| 309 |
onResize={resizeWidget}
|
| 310 |
openModal={openModal}
|
| 311 |
+
allStates={widgetStates}
|
| 312 |
/>
|
| 313 |
))}
|
| 314 |
</div>
|
|
|
|
| 337 |
case 'goal': content = <GoalModal data={modal.data} onClose={onClose}/>; break;
|
| 338 |
case 'meeting': content = <MeetingModal data={modal.data} onClose={onClose}/>; break;
|
| 339 |
case 'command': content = <CommandPaletteModal data={modal.data} onClose={onClose}/>; break;
|
| 340 |
+
case 'global-search': content = <GlobalSearchModal data={modal.data} onClose={onClose}/>; break;
|
| 341 |
default: return null;
|
| 342 |
}
|
| 343 |
return <div className="canvas-modal-backdrop" onClick={handleBackdropClick}>{content}</div>;
|
|
|
|
| 428 |
});
|
| 429 |
}, [openModal, layout, toggleTheme, exportWorkspace]);
|
| 430 |
|
| 431 |
+
const openGlobalSearch = useCallback(() => {
|
| 432 |
+
openModal('global-search', { states: widgetStates });
|
| 433 |
+
}, [openModal, widgetStates]);
|
| 434 |
+
|
| 435 |
+
// Esc closes modal, ⌘K opens command palette, ⌘/ opens global content search
|
| 436 |
useEffect(() => {
|
| 437 |
const k = (e) => {
|
| 438 |
if (e.key === 'Escape') closeModal();
|
|
|
|
| 440 |
e.preventDefault();
|
| 441 |
openCommandPalette();
|
| 442 |
}
|
| 443 |
+
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
|
| 444 |
+
e.preventDefault();
|
| 445 |
+
openGlobalSearch();
|
| 446 |
+
}
|
| 447 |
};
|
| 448 |
window.addEventListener('keydown', k);
|
| 449 |
return () => window.removeEventListener('keydown', k);
|
| 450 |
+
}, [closeModal, openCommandPalette, openGlobalSearch]);
|
| 451 |
|
| 452 |
const canvasSidebarItems = layout.map(w => {
|
| 453 |
const meta = WIDGET_CATALOG.find(m => m.type === w.type);
|
|
|
|
| 484 |
<div className={`canvas-main-area ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
| 485 |
<div className="canvas-app-shell">
|
| 486 |
<AppHeader
|
| 487 |
+
currentPage={`canvas-${view}`}
|
| 488 |
onNavigateToHome={onNavigateToHome}
|
| 489 |
onNavigateToChat={onNavigateToChat}
|
| 490 |
onNavigateToCanvas={(v) => setView(v || 'workspace')}
|
|
|
|
| 493 |
<button className="icon-btn" onClick={() => setTourForceShow(n => n + 1)} title="Show tour">
|
| 494 |
<HelpCircle size={18}/>
|
| 495 |
</button>
|
| 496 |
+
<button className="icon-btn" onClick={openGlobalSearch} title="Search canvas content (⌘/)">
|
| 497 |
<Icon name="search" size={16}/>
|
| 498 |
</button>
|
| 499 |
+
<button className="icon-btn" onClick={openCommandPalette} title="Commands (⌘K)">
|
| 500 |
+
<Icon name="zap" size={16}/>
|
| 501 |
+
</button>
|
| 502 |
</AppHeader>
|
| 503 |
<div className="canvas-content">
|
| 504 |
+
{view === 'insights' && <InsightsView widgetStates={widgetStates} setWidgetStates={setWidgetStates}/>}
|
| 505 |
+
{view === 'workspace' && <WorkspaceView openModal={openModal} layout={layout} setLayout={setLayout} widgetStates={widgetStates} setWidgetStates={setWidgetStates}/>}
|
| 506 |
+
{view === 'deliverables' && <DeliverablesView allStates={widgetStates}/>}
|
| 507 |
</div>
|
| 508 |
</div>
|
| 509 |
</div>
|
phd-advisor-frontend/src/styles/CanvasPage.css
CHANGED
|
@@ -321,8 +321,8 @@
|
|
| 321 |
gap: 24px;
|
| 322 |
margin-bottom: 22px;
|
| 323 |
}
|
| 324 |
-
.canvas-page-with-sidebar .page-title { font-size:
|
| 325 |
-
.canvas-page-with-sidebar .page-sub { color: var(--canvas-text-3); font-size: 13px; margin-top:
|
| 326 |
.canvas-page-with-sidebar .page-meta {
|
| 327 |
font-family: var(--canvas-mono);
|
| 328 |
font-size: 11px;
|
|
@@ -435,37 +435,43 @@
|
|
| 435 |
.canvas-page-with-sidebar .workspace {
|
| 436 |
display: grid;
|
| 437 |
grid-template-columns: repeat(6, minmax(0, 1fr));
|
| 438 |
-
gap:
|
| 439 |
align-items: start;
|
| 440 |
}
|
|
|
|
|
|
|
| 441 |
.canvas-page-with-sidebar .widget {
|
| 442 |
background: var(--canvas-surface);
|
| 443 |
-
border: 1px solid
|
| 444 |
-
border-radius:
|
| 445 |
display: flex;
|
| 446 |
flex-direction: column;
|
| 447 |
min-height: 200px;
|
| 448 |
position: relative;
|
| 449 |
overflow: hidden;
|
| 450 |
-
transition:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
}
|
| 452 |
.canvas-page-with-sidebar .widget.size-S { grid-column: span 2; }
|
| 453 |
.canvas-page-with-sidebar .widget.size-M { grid-column: span 3; }
|
| 454 |
.canvas-page-with-sidebar .widget.size-L { grid-column: span 6; }
|
| 455 |
.canvas-page-with-sidebar .widget.dragging { opacity: 0.4; }
|
| 456 |
-
.canvas-page-with-sidebar .widget.drag-over {
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
.canvas-page-with-sidebar .widget
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
background: linear-gradient(90deg, transparent, var(--canvas-critic), transparent);
|
| 466 |
-
opacity: 0.7;
|
| 467 |
}
|
| 468 |
-
.canvas-page-with-sidebar
|
|
|
|
|
|
|
| 469 |
|
| 470 |
/* Preserve 3-S / 2-M / 1-L per row at every desktop width.
|
| 471 |
On phones (≤768px) just stack everything full-width for readability. */
|
|
@@ -476,11 +482,12 @@
|
|
| 476 |
.canvas-page-with-sidebar .widget.size-L { grid-column: 1 / -1; }
|
| 477 |
}
|
| 478 |
|
|
|
|
| 479 |
.canvas-page-with-sidebar .widget-head {
|
| 480 |
display: flex;
|
| 481 |
align-items: center;
|
| 482 |
gap: 8px;
|
| 483 |
-
padding:
|
| 484 |
cursor: grab;
|
| 485 |
}
|
| 486 |
.canvas-page-with-sidebar .widget-head:active { cursor: grabbing; }
|
|
@@ -488,18 +495,26 @@
|
|
| 488 |
color: var(--canvas-text-4);
|
| 489 |
display: grid;
|
| 490 |
place-items: center;
|
| 491 |
-
width:
|
|
|
|
|
|
|
| 492 |
}
|
| 493 |
-
.canvas-page-with-sidebar .widget
|
| 494 |
.canvas-page-with-sidebar .widget-icon {
|
| 495 |
-
width:
|
| 496 |
-
height:
|
| 497 |
display: grid;
|
| 498 |
place-items: center;
|
| 499 |
-
color: var(--canvas-
|
| 500 |
}
|
| 501 |
.canvas-page-with-sidebar .widget.critic .widget-icon { color: var(--canvas-critic); }
|
| 502 |
-
.canvas-page-with-sidebar .widget-title {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
.canvas-page-with-sidebar .widget-tag,
|
| 504 |
.canvas-modal-backdrop .widget-tag {
|
| 505 |
font-size: 9.5px;
|
|
@@ -533,14 +548,82 @@
|
|
| 533 |
.canvas-page-with-sidebar .size-pill:hover { background: var(--canvas-surface-3); color: var(--canvas-text); }
|
| 534 |
|
| 535 |
.canvas-page-with-sidebar .widget-body {
|
| 536 |
-
padding: 4px
|
| 537 |
flex: 1;
|
| 538 |
display: flex;
|
| 539 |
flex-direction: column;
|
| 540 |
-
gap:
|
| 541 |
min-height: 0;
|
| 542 |
}
|
| 543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
.canvas-page-with-sidebar .empty-cell {
|
| 545 |
border: 1.5px dashed var(--canvas-border-2);
|
| 546 |
border-radius: var(--canvas-r-md);
|
|
@@ -1184,6 +1267,597 @@ body[data-canvas-theme="light"] .canvas-modal-backdrop .critic-meter .bar { back
|
|
| 1184 |
.canvas-page-with-sidebar .row-actions .icon-btn svg { width: 12px; height: 12px; }
|
| 1185 |
.canvas-page-with-sidebar .tag-pill { display: inline-block; font-family: var(--canvas-mono); font-size: 9.5px; padding: 1px 6px; border-radius: 3px; background: var(--canvas-accent-glow); color: var(--canvas-accent); text-transform: uppercase; letter-spacing: 0.06em; }
|
| 1186 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1187 |
/* Compact markdown rendering inside note rows */
|
| 1188 |
.canvas-page-with-sidebar .canvas-md p { margin: 0 0 4px; }
|
| 1189 |
.canvas-page-with-sidebar .canvas-md p:last-child { margin-bottom: 0; }
|
|
|
|
| 321 |
gap: 24px;
|
| 322 |
margin-bottom: 22px;
|
| 323 |
}
|
| 324 |
+
.canvas-page-with-sidebar .page-title { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; margin: 0; color: var(--canvas-text); }
|
| 325 |
+
.canvas-page-with-sidebar .page-sub { color: var(--canvas-text-3); font-size: 13px; margin-top: 6px; }
|
| 326 |
.canvas-page-with-sidebar .page-meta {
|
| 327 |
font-family: var(--canvas-mono);
|
| 328 |
font-size: 11px;
|
|
|
|
| 435 |
.canvas-page-with-sidebar .workspace {
|
| 436 |
display: grid;
|
| 437 |
grid-template-columns: repeat(6, minmax(0, 1fr));
|
| 438 |
+
gap: 18px;
|
| 439 |
align-items: start;
|
| 440 |
}
|
| 441 |
+
/* Notion-inspired widget treatment: hairline borders, generous padding,
|
| 442 |
+
no hard chrome — the content is the page, the frame is barely there. */
|
| 443 |
.canvas-page-with-sidebar .widget {
|
| 444 |
background: var(--canvas-surface);
|
| 445 |
+
border: 1px solid transparent;
|
| 446 |
+
border-radius: 6px;
|
| 447 |
display: flex;
|
| 448 |
flex-direction: column;
|
| 449 |
min-height: 200px;
|
| 450 |
position: relative;
|
| 451 |
overflow: hidden;
|
| 452 |
+
transition: background .15s, border-color .15s, box-shadow .15s;
|
| 453 |
+
}
|
| 454 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .widget {
|
| 455 |
+
background: #FFFFFF;
|
| 456 |
+
border-color: rgba(15, 15, 15, 0.06);
|
| 457 |
}
|
| 458 |
.canvas-page-with-sidebar .widget.size-S { grid-column: span 2; }
|
| 459 |
.canvas-page-with-sidebar .widget.size-M { grid-column: span 3; }
|
| 460 |
.canvas-page-with-sidebar .widget.size-L { grid-column: span 6; }
|
| 461 |
.canvas-page-with-sidebar .widget.dragging { opacity: 0.4; }
|
| 462 |
+
.canvas-page-with-sidebar .widget.drag-over {
|
| 463 |
+
box-shadow: 0 0 0 2px var(--canvas-accent), 0 4px 14px var(--canvas-accent-glow);
|
| 464 |
+
}
|
| 465 |
+
.canvas-page-with-sidebar .widget:hover {
|
| 466 |
+
background: var(--canvas-surface-2);
|
| 467 |
+
}
|
| 468 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .widget:hover {
|
| 469 |
+
background: rgba(0, 0, 0, 0.015);
|
| 470 |
+
border-color: rgba(15, 15, 15, 0.08);
|
|
|
|
|
|
|
| 471 |
}
|
| 472 |
+
.canvas-page-with-sidebar .widget.critic { border-color: rgba(167, 139, 250, 0.18); }
|
| 473 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .widget.critic { border-color: rgba(139, 92, 246, 0.18); }
|
| 474 |
+
.canvas-page-with-sidebar .widget.critic:hover { border-color: var(--canvas-critic); }
|
| 475 |
|
| 476 |
/* Preserve 3-S / 2-M / 1-L per row at every desktop width.
|
| 477 |
On phones (≤768px) just stack everything full-width for readability. */
|
|
|
|
| 482 |
.canvas-page-with-sidebar .widget.size-L { grid-column: 1 / -1; }
|
| 483 |
}
|
| 484 |
|
| 485 |
+
/* Notion-style header: drag grip is hover-only, title is plain text. */
|
| 486 |
.canvas-page-with-sidebar .widget-head {
|
| 487 |
display: flex;
|
| 488 |
align-items: center;
|
| 489 |
gap: 8px;
|
| 490 |
+
padding: 14px 16px 8px;
|
| 491 |
cursor: grab;
|
| 492 |
}
|
| 493 |
.canvas-page-with-sidebar .widget-head:active { cursor: grabbing; }
|
|
|
|
| 495 |
color: var(--canvas-text-4);
|
| 496 |
display: grid;
|
| 497 |
place-items: center;
|
| 498 |
+
width: 14px;
|
| 499 |
+
opacity: 0;
|
| 500 |
+
transition: opacity .15s;
|
| 501 |
}
|
| 502 |
+
.canvas-page-with-sidebar .widget:hover .widget-head .drag-grip { opacity: 0.6; }
|
| 503 |
.canvas-page-with-sidebar .widget-icon {
|
| 504 |
+
width: 18px;
|
| 505 |
+
height: 18px;
|
| 506 |
display: grid;
|
| 507 |
place-items: center;
|
| 508 |
+
color: var(--canvas-text-3);
|
| 509 |
}
|
| 510 |
.canvas-page-with-sidebar .widget.critic .widget-icon { color: var(--canvas-critic); }
|
| 511 |
+
.canvas-page-with-sidebar .widget-title {
|
| 512 |
+
font-weight: 600;
|
| 513 |
+
font-size: 13.5px;
|
| 514 |
+
flex: 1;
|
| 515 |
+
letter-spacing: -0.005em;
|
| 516 |
+
color: var(--canvas-text);
|
| 517 |
+
}
|
| 518 |
.canvas-page-with-sidebar .widget-tag,
|
| 519 |
.canvas-modal-backdrop .widget-tag {
|
| 520 |
font-size: 9.5px;
|
|
|
|
| 548 |
.canvas-page-with-sidebar .size-pill:hover { background: var(--canvas-surface-3); color: var(--canvas-text); }
|
| 549 |
|
| 550 |
.canvas-page-with-sidebar .widget-body {
|
| 551 |
+
padding: 4px 16px 18px;
|
| 552 |
flex: 1;
|
| 553 |
display: flex;
|
| 554 |
flex-direction: column;
|
| 555 |
+
gap: 12px;
|
| 556 |
min-height: 0;
|
| 557 |
}
|
| 558 |
|
| 559 |
+
/* Preset picker (shown above the empty workspace) */
|
| 560 |
+
.canvas-page-with-sidebar .canvas-presets {
|
| 561 |
+
margin-bottom: 18px;
|
| 562 |
+
}
|
| 563 |
+
.canvas-page-with-sidebar .canvas-presets-head {
|
| 564 |
+
margin-bottom: 10px;
|
| 565 |
+
}
|
| 566 |
+
.canvas-page-with-sidebar .canvas-presets-title {
|
| 567 |
+
font-size: 16px;
|
| 568 |
+
font-weight: 600;
|
| 569 |
+
color: var(--canvas-text);
|
| 570 |
+
}
|
| 571 |
+
.canvas-page-with-sidebar .canvas-presets-sub {
|
| 572 |
+
font-size: 12.5px;
|
| 573 |
+
color: var(--canvas-text-3);
|
| 574 |
+
margin-top: 2px;
|
| 575 |
+
}
|
| 576 |
+
.canvas-page-with-sidebar .canvas-presets-grid {
|
| 577 |
+
display: grid;
|
| 578 |
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 579 |
+
gap: 10px;
|
| 580 |
+
}
|
| 581 |
+
.canvas-page-with-sidebar .canvas-preset-card {
|
| 582 |
+
background: transparent;
|
| 583 |
+
border: 1px solid rgba(15, 15, 15, 0.10);
|
| 584 |
+
border-radius: 8px;
|
| 585 |
+
padding: 16px;
|
| 586 |
+
display: flex;
|
| 587 |
+
gap: 12px;
|
| 588 |
+
text-align: left;
|
| 589 |
+
cursor: pointer;
|
| 590 |
+
transition: background .15s, border-color .15s;
|
| 591 |
+
font-family: inherit;
|
| 592 |
+
color: var(--canvas-text);
|
| 593 |
+
}
|
| 594 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .canvas-preset-card {
|
| 595 |
+
border-color: rgba(15, 15, 15, 0.12);
|
| 596 |
+
}
|
| 597 |
+
.canvas-page-with-sidebar .canvas-preset-card:hover {
|
| 598 |
+
background: var(--canvas-surface-2);
|
| 599 |
+
border-color: rgba(15, 15, 15, 0.15);
|
| 600 |
+
}
|
| 601 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .canvas-preset-card:hover {
|
| 602 |
+
background: rgba(0, 0, 0, 0.025);
|
| 603 |
+
}
|
| 604 |
+
.canvas-page-with-sidebar .canvas-preset-icon {
|
| 605 |
+
width: 32px;
|
| 606 |
+
height: 32px;
|
| 607 |
+
border-radius: 6px;
|
| 608 |
+
background: var(--canvas-surface-2);
|
| 609 |
+
color: var(--canvas-text-2);
|
| 610 |
+
display: grid;
|
| 611 |
+
place-items: center;
|
| 612 |
+
flex-shrink: 0;
|
| 613 |
+
font-size: 16px;
|
| 614 |
+
}
|
| 615 |
+
.canvas-page-with-sidebar .canvas-preset-content { flex: 1; min-width: 0; }
|
| 616 |
+
.canvas-page-with-sidebar .canvas-preset-name { font-size: 13.5px; font-weight: 600; color: var(--canvas-text); }
|
| 617 |
+
.canvas-page-with-sidebar .canvas-preset-desc { font-size: 11.5px; color: var(--canvas-text-3); margin-top: 3px; line-height: 1.45; }
|
| 618 |
+
.canvas-page-with-sidebar .canvas-preset-meta {
|
| 619 |
+
font-family: var(--canvas-mono);
|
| 620 |
+
font-size: 10px;
|
| 621 |
+
color: var(--canvas-text-4);
|
| 622 |
+
margin-top: 6px;
|
| 623 |
+
text-transform: uppercase;
|
| 624 |
+
letter-spacing: 0.06em;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
.canvas-page-with-sidebar .empty-cell {
|
| 628 |
border: 1.5px dashed var(--canvas-border-2);
|
| 629 |
border-radius: var(--canvas-r-md);
|
|
|
|
| 1267 |
.canvas-page-with-sidebar .row-actions .icon-btn svg { width: 12px; height: 12px; }
|
| 1268 |
.canvas-page-with-sidebar .tag-pill { display: inline-block; font-family: var(--canvas-mono); font-size: 9.5px; padding: 1px 6px; border-radius: 3px; background: var(--canvas-accent-glow); color: var(--canvas-accent); text-transform: uppercase; letter-spacing: 0.06em; }
|
| 1269 |
|
| 1270 |
+
/* ----- Deliverables view ----- */
|
| 1271 |
+
|
| 1272 |
+
/* Paper / document mode — three-column layout that collapses on narrow screens */
|
| 1273 |
+
.canvas-page-with-sidebar .deliverable-grid {
|
| 1274 |
+
display: grid;
|
| 1275 |
+
grid-template-columns: 180px minmax(0, 1fr) 240px;
|
| 1276 |
+
gap: 16px;
|
| 1277 |
+
align-items: start;
|
| 1278 |
+
}
|
| 1279 |
+
.canvas-page-with-sidebar .deliverable-nav {
|
| 1280 |
+
display: flex;
|
| 1281 |
+
flex-direction: column;
|
| 1282 |
+
gap: 4px;
|
| 1283 |
+
position: sticky;
|
| 1284 |
+
top: 0;
|
| 1285 |
+
}
|
| 1286 |
+
|
| 1287 |
+
/* Slides mode — thumbs on left, big canvas in middle, panel on right */
|
| 1288 |
+
.canvas-page-with-sidebar .deliverable-slides-grid {
|
| 1289 |
+
display: grid;
|
| 1290 |
+
grid-template-columns: 160px minmax(0, 1fr) 240px;
|
| 1291 |
+
gap: 16px;
|
| 1292 |
+
align-items: start;
|
| 1293 |
+
}
|
| 1294 |
+
@media (max-width: 1280px) {
|
| 1295 |
+
.canvas-page-with-sidebar .deliverable-slides-grid { grid-template-columns: 140px minmax(0, 1fr); }
|
| 1296 |
+
.canvas-page-with-sidebar .deliverable-slides-grid > .deliverable-insertables { display: none; }
|
| 1297 |
+
}
|
| 1298 |
+
@media (max-width: 768px) {
|
| 1299 |
+
.canvas-page-with-sidebar .deliverable-slides-grid { grid-template-columns: 1fr; }
|
| 1300 |
+
.canvas-page-with-sidebar .slide-thumbs { display: none; }
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
/* Slide thumbnails column */
|
| 1304 |
+
.canvas-page-with-sidebar .slide-thumbs {
|
| 1305 |
+
display: flex;
|
| 1306 |
+
flex-direction: column;
|
| 1307 |
+
gap: 8px;
|
| 1308 |
+
position: sticky;
|
| 1309 |
+
top: 0;
|
| 1310 |
+
max-height: calc(100vh - 100px);
|
| 1311 |
+
overflow-y: auto;
|
| 1312 |
+
padding-right: 4px;
|
| 1313 |
+
}
|
| 1314 |
+
.canvas-page-with-sidebar .slide-thumb {
|
| 1315 |
+
display: flex;
|
| 1316 |
+
align-items: stretch;
|
| 1317 |
+
gap: 6px;
|
| 1318 |
+
padding: 0;
|
| 1319 |
+
background: transparent;
|
| 1320 |
+
border: none;
|
| 1321 |
+
cursor: pointer;
|
| 1322 |
+
font-family: inherit;
|
| 1323 |
+
text-align: left;
|
| 1324 |
+
}
|
| 1325 |
+
.canvas-page-with-sidebar .slide-thumb-num {
|
| 1326 |
+
width: 18px;
|
| 1327 |
+
font-family: var(--canvas-mono);
|
| 1328 |
+
font-size: 10px;
|
| 1329 |
+
color: var(--canvas-text-3);
|
| 1330 |
+
padding-top: 4px;
|
| 1331 |
+
flex-shrink: 0;
|
| 1332 |
+
text-align: right;
|
| 1333 |
+
}
|
| 1334 |
+
.canvas-page-with-sidebar .slide-thumb-canvas {
|
| 1335 |
+
flex: 1;
|
| 1336 |
+
background: #fff;
|
| 1337 |
+
border: 1px solid var(--canvas-border-2);
|
| 1338 |
+
border-radius: 4px;
|
| 1339 |
+
aspect-ratio: 16 / 9;
|
| 1340 |
+
padding: 5px 6px;
|
| 1341 |
+
overflow: hidden;
|
| 1342 |
+
color: #111;
|
| 1343 |
+
display: flex;
|
| 1344 |
+
flex-direction: column;
|
| 1345 |
+
gap: 3px;
|
| 1346 |
+
transition: all .15s;
|
| 1347 |
+
}
|
| 1348 |
+
.canvas-page-with-sidebar .slide-thumb:hover .slide-thumb-canvas {
|
| 1349 |
+
border-color: var(--canvas-accent);
|
| 1350 |
+
transform: translateY(-1px);
|
| 1351 |
+
}
|
| 1352 |
+
.canvas-page-with-sidebar .slide-thumb.active .slide-thumb-canvas {
|
| 1353 |
+
border: 2px solid var(--canvas-accent);
|
| 1354 |
+
box-shadow: 0 0 0 2px var(--canvas-accent-glow);
|
| 1355 |
+
padding: 4px 5px;
|
| 1356 |
+
}
|
| 1357 |
+
.canvas-page-with-sidebar .slide-thumb.active .slide-thumb-num { color: var(--canvas-accent); font-weight: 700; }
|
| 1358 |
+
.canvas-page-with-sidebar .slide-thumb-title {
|
| 1359 |
+
font-size: 8px;
|
| 1360 |
+
font-weight: 700;
|
| 1361 |
+
color: #1a1a1a;
|
| 1362 |
+
letter-spacing: -0.01em;
|
| 1363 |
+
line-height: 1.1;
|
| 1364 |
+
overflow: hidden;
|
| 1365 |
+
text-overflow: ellipsis;
|
| 1366 |
+
white-space: nowrap;
|
| 1367 |
+
}
|
| 1368 |
+
.canvas-page-with-sidebar .slide-thumb-body {
|
| 1369 |
+
font-size: 6.5px;
|
| 1370 |
+
color: #555;
|
| 1371 |
+
line-height: 1.25;
|
| 1372 |
+
overflow: hidden;
|
| 1373 |
+
flex: 1;
|
| 1374 |
+
}
|
| 1375 |
+
|
| 1376 |
+
/* Big slide canvas */
|
| 1377 |
+
.canvas-page-with-sidebar .slide-canvas-wrap {
|
| 1378 |
+
background: var(--canvas-surface-2);
|
| 1379 |
+
border: 1px solid var(--canvas-border);
|
| 1380 |
+
border-radius: 10px;
|
| 1381 |
+
padding: 18px;
|
| 1382 |
+
display: flex;
|
| 1383 |
+
align-items: center;
|
| 1384 |
+
justify-content: center;
|
| 1385 |
+
}
|
| 1386 |
+
.canvas-page-with-sidebar .slide-canvas {
|
| 1387 |
+
position: relative;
|
| 1388 |
+
width: 100%;
|
| 1389 |
+
max-width: 720px;
|
| 1390 |
+
aspect-ratio: 16 / 9;
|
| 1391 |
+
background: #fff;
|
| 1392 |
+
border-radius: 6px;
|
| 1393 |
+
box-shadow: 0 12px 32px rgba(0,0,0,0.18), 0 4px 10px rgba(0,0,0,0.10);
|
| 1394 |
+
padding: 36px 44px;
|
| 1395 |
+
display: flex;
|
| 1396 |
+
flex-direction: column;
|
| 1397 |
+
gap: 16px;
|
| 1398 |
+
color: #111;
|
| 1399 |
+
overflow: hidden;
|
| 1400 |
+
}
|
| 1401 |
+
.canvas-page-with-sidebar .slide-canvas::before {
|
| 1402 |
+
content: '';
|
| 1403 |
+
position: absolute;
|
| 1404 |
+
top: 0; left: 0; right: 0;
|
| 1405 |
+
height: 4px;
|
| 1406 |
+
background: linear-gradient(90deg, var(--canvas-accent), var(--canvas-critic));
|
| 1407 |
+
}
|
| 1408 |
+
.canvas-page-with-sidebar .slide-canvas-title {
|
| 1409 |
+
font-size: 26px;
|
| 1410 |
+
font-weight: 700;
|
| 1411 |
+
letter-spacing: -0.02em;
|
| 1412 |
+
color: #0f172a;
|
| 1413 |
+
line-height: 1.15;
|
| 1414 |
+
}
|
| 1415 |
+
.canvas-page-with-sidebar .slide-canvas-body {
|
| 1416 |
+
flex: 1;
|
| 1417 |
+
font-size: 15px;
|
| 1418 |
+
color: #1f2937;
|
| 1419 |
+
line-height: 1.55;
|
| 1420 |
+
overflow: hidden;
|
| 1421 |
+
}
|
| 1422 |
+
.canvas-page-with-sidebar .slide-canvas-body ul {
|
| 1423 |
+
list-style: none;
|
| 1424 |
+
padding: 0;
|
| 1425 |
+
margin: 0;
|
| 1426 |
+
display: flex;
|
| 1427 |
+
flex-direction: column;
|
| 1428 |
+
gap: 8px;
|
| 1429 |
+
}
|
| 1430 |
+
.canvas-page-with-sidebar .slide-canvas-body ul li {
|
| 1431 |
+
position: relative;
|
| 1432 |
+
padding-left: 22px;
|
| 1433 |
+
}
|
| 1434 |
+
.canvas-page-with-sidebar .slide-canvas-body ul li::before {
|
| 1435 |
+
content: '';
|
| 1436 |
+
position: absolute;
|
| 1437 |
+
left: 0;
|
| 1438 |
+
top: 9px;
|
| 1439 |
+
width: 7px;
|
| 1440 |
+
height: 7px;
|
| 1441 |
+
border-radius: 50%;
|
| 1442 |
+
background: var(--canvas-accent);
|
| 1443 |
+
}
|
| 1444 |
+
.canvas-page-with-sidebar .slide-paragraph {
|
| 1445 |
+
font-size: 17px;
|
| 1446 |
+
line-height: 1.6;
|
| 1447 |
+
}
|
| 1448 |
+
.canvas-page-with-sidebar .slide-placeholder {
|
| 1449 |
+
color: #9ca3af;
|
| 1450 |
+
font-style: italic;
|
| 1451 |
+
font-size: 14px;
|
| 1452 |
+
}
|
| 1453 |
+
.canvas-page-with-sidebar .slide-canvas-footer {
|
| 1454 |
+
position: absolute;
|
| 1455 |
+
bottom: 14px;
|
| 1456 |
+
right: 18px;
|
| 1457 |
+
font-family: var(--canvas-mono);
|
| 1458 |
+
font-size: 10px;
|
| 1459 |
+
color: #9ca3af;
|
| 1460 |
+
}
|
| 1461 |
+
|
| 1462 |
+
/* Section-check pill (used in both modes) */
|
| 1463 |
+
.canvas-page-with-sidebar .check-pill {
|
| 1464 |
+
display: inline-flex;
|
| 1465 |
+
align-items: center;
|
| 1466 |
+
gap: 6px;
|
| 1467 |
+
font-size: 11px;
|
| 1468 |
+
padding: 3px 9px;
|
| 1469 |
+
border-radius: 999px;
|
| 1470 |
+
background: var(--canvas-surface-2);
|
| 1471 |
+
color: var(--canvas-text-3);
|
| 1472 |
+
font-family: var(--canvas-sans);
|
| 1473 |
+
}
|
| 1474 |
+
.canvas-page-with-sidebar .check-pill[data-passed="true"] {
|
| 1475 |
+
background: rgba(16, 185, 129, 0.15);
|
| 1476 |
+
color: #10B981;
|
| 1477 |
+
}
|
| 1478 |
+
|
| 1479 |
+
/* Document-page preview (Google Docs feel) */
|
| 1480 |
+
.canvas-page-with-sidebar .doc-page {
|
| 1481 |
+
background: #ffffff;
|
| 1482 |
+
color: #1a1a1a;
|
| 1483 |
+
padding: 56px 72px;
|
| 1484 |
+
border-radius: 4px;
|
| 1485 |
+
box-shadow: 0 4px 14px rgba(0,0,0,0.10), 0 1px 4px rgba(0,0,0,0.06);
|
| 1486 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 1487 |
+
max-width: 800px;
|
| 1488 |
+
margin: 0 auto;
|
| 1489 |
+
width: 100%;
|
| 1490 |
+
}
|
| 1491 |
+
.canvas-page-with-sidebar .doc-title {
|
| 1492 |
+
font-size: 28px;
|
| 1493 |
+
font-weight: 700;
|
| 1494 |
+
margin: 0 0 24px;
|
| 1495 |
+
letter-spacing: -0.02em;
|
| 1496 |
+
color: #0f172a;
|
| 1497 |
+
}
|
| 1498 |
+
.canvas-page-with-sidebar .doc-h2 {
|
| 1499 |
+
font-size: 18px;
|
| 1500 |
+
font-weight: 700;
|
| 1501 |
+
margin: 24px 0 8px;
|
| 1502 |
+
color: #0f172a;
|
| 1503 |
+
}
|
| 1504 |
+
.canvas-page-with-sidebar .doc-body { font-size: 14px; line-height: 1.7; color: #1f2937; }
|
| 1505 |
+
.canvas-page-with-sidebar .doc-body p { margin: 0 0 12px; }
|
| 1506 |
+
.canvas-page-with-sidebar .doc-body strong { color: #0f172a; }
|
| 1507 |
+
.canvas-page-with-sidebar .doc-section { margin-bottom: 8px; }
|
| 1508 |
+
|
| 1509 |
+
/* Paper-page preview (Overleaf feel — serif, two-column body) */
|
| 1510 |
+
.canvas-page-with-sidebar .paper-page {
|
| 1511 |
+
background: #fdfbf7;
|
| 1512 |
+
color: #1a1a1a;
|
| 1513 |
+
padding: 64px 72px;
|
| 1514 |
+
border-radius: 4px;
|
| 1515 |
+
box-shadow: 0 4px 14px rgba(0,0,0,0.10), 0 1px 4px rgba(0,0,0,0.06);
|
| 1516 |
+
font-family: 'Georgia', 'Times New Roman', serif;
|
| 1517 |
+
max-width: 820px;
|
| 1518 |
+
margin: 0 auto;
|
| 1519 |
+
width: 100%;
|
| 1520 |
+
}
|
| 1521 |
+
.canvas-page-with-sidebar .paper-page-head {
|
| 1522 |
+
text-align: center;
|
| 1523 |
+
border-bottom: 1px solid #d1d5db;
|
| 1524 |
+
padding-bottom: 16px;
|
| 1525 |
+
margin-bottom: 22px;
|
| 1526 |
+
}
|
| 1527 |
+
.canvas-page-with-sidebar .paper-title {
|
| 1528 |
+
font-size: 22px;
|
| 1529 |
+
font-weight: 700;
|
| 1530 |
+
letter-spacing: -0.01em;
|
| 1531 |
+
color: #0f172a;
|
| 1532 |
+
}
|
| 1533 |
+
.canvas-page-with-sidebar .paper-byline {
|
| 1534 |
+
font-size: 12px;
|
| 1535 |
+
color: #6b7280;
|
| 1536 |
+
font-style: italic;
|
| 1537 |
+
margin-top: 6px;
|
| 1538 |
+
}
|
| 1539 |
+
.canvas-page-with-sidebar .paper-h2 {
|
| 1540 |
+
font-size: 14px;
|
| 1541 |
+
font-weight: 700;
|
| 1542 |
+
text-transform: uppercase;
|
| 1543 |
+
letter-spacing: 0.08em;
|
| 1544 |
+
color: #0f172a;
|
| 1545 |
+
margin: 22px 0 8px;
|
| 1546 |
+
}
|
| 1547 |
+
.canvas-page-with-sidebar .paper-body { font-size: 13px; line-height: 1.65; color: #1f2937; text-align: justify; }
|
| 1548 |
+
.canvas-page-with-sidebar .paper-body p { margin: 0 0 10px; text-indent: 1.5em; }
|
| 1549 |
+
.canvas-page-with-sidebar .paper-body p:first-child { text-indent: 0; }
|
| 1550 |
+
.canvas-page-with-sidebar .paper-empty { color: #9ca3af; font-style: italic; font-size: 12px; }
|
| 1551 |
+
.canvas-page-with-sidebar .paper-section { margin-bottom: 4px; }
|
| 1552 |
+
|
| 1553 |
+
/* ----- Notion-style single-surface deliverable editor ----- */
|
| 1554 |
+
.canvas-page-with-sidebar .notion-deliverable-grid {
|
| 1555 |
+
display: grid;
|
| 1556 |
+
grid-template-columns: 200px minmax(0, 1fr) 240px;
|
| 1557 |
+
gap: 28px;
|
| 1558 |
+
align-items: start;
|
| 1559 |
+
}
|
| 1560 |
+
@media (max-width: 1280px) {
|
| 1561 |
+
.canvas-page-with-sidebar .notion-deliverable-grid { grid-template-columns: 200px minmax(0, 1fr); }
|
| 1562 |
+
.canvas-page-with-sidebar .notion-deliverable-grid > .deliverable-insertables { display: none; }
|
| 1563 |
+
}
|
| 1564 |
+
@media (max-width: 768px) {
|
| 1565 |
+
.canvas-page-with-sidebar .notion-deliverable-grid { grid-template-columns: 1fr; }
|
| 1566 |
+
.canvas-page-with-sidebar .notion-toc { display: none; }
|
| 1567 |
+
}
|
| 1568 |
+
|
| 1569 |
+
/* Subtle TOC */
|
| 1570 |
+
.canvas-page-with-sidebar .notion-toc {
|
| 1571 |
+
display: flex;
|
| 1572 |
+
flex-direction: column;
|
| 1573 |
+
gap: 1px;
|
| 1574 |
+
position: sticky;
|
| 1575 |
+
top: 0;
|
| 1576 |
+
padding: 4px 0;
|
| 1577 |
+
}
|
| 1578 |
+
.canvas-page-with-sidebar .notion-toc-label {
|
| 1579 |
+
font-size: 11px;
|
| 1580 |
+
color: var(--canvas-text-4);
|
| 1581 |
+
font-weight: 500;
|
| 1582 |
+
padding: 4px 6px 8px;
|
| 1583 |
+
letter-spacing: 0;
|
| 1584 |
+
text-transform: none;
|
| 1585 |
+
}
|
| 1586 |
+
.canvas-page-with-sidebar .notion-toc-link {
|
| 1587 |
+
display: flex;
|
| 1588 |
+
align-items: center;
|
| 1589 |
+
gap: 8px;
|
| 1590 |
+
background: transparent;
|
| 1591 |
+
border: none;
|
| 1592 |
+
padding: 5px 8px;
|
| 1593 |
+
border-radius: 4px;
|
| 1594 |
+
cursor: pointer;
|
| 1595 |
+
font-family: inherit;
|
| 1596 |
+
font-size: 13px;
|
| 1597 |
+
color: var(--canvas-text-2);
|
| 1598 |
+
text-align: left;
|
| 1599 |
+
transition: background .12s, color .12s;
|
| 1600 |
+
}
|
| 1601 |
+
.canvas-page-with-sidebar .notion-toc-link:hover {
|
| 1602 |
+
background: rgba(255, 255, 255, 0.04);
|
| 1603 |
+
color: var(--canvas-text);
|
| 1604 |
+
}
|
| 1605 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .notion-toc-link:hover {
|
| 1606 |
+
background: rgba(0, 0, 0, 0.04);
|
| 1607 |
+
}
|
| 1608 |
+
.canvas-page-with-sidebar .notion-toc-link.active {
|
| 1609 |
+
background: rgba(255, 255, 255, 0.06);
|
| 1610 |
+
color: var(--canvas-text);
|
| 1611 |
+
font-weight: 500;
|
| 1612 |
+
}
|
| 1613 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .notion-toc-link.active {
|
| 1614 |
+
background: rgba(0, 0, 0, 0.05);
|
| 1615 |
+
}
|
| 1616 |
+
.canvas-page-with-sidebar .notion-toc-link-text {
|
| 1617 |
+
flex: 1;
|
| 1618 |
+
overflow: hidden;
|
| 1619 |
+
text-overflow: ellipsis;
|
| 1620 |
+
white-space: nowrap;
|
| 1621 |
+
}
|
| 1622 |
+
.canvas-page-with-sidebar .notion-toc-link-count {
|
| 1623 |
+
font-family: var(--canvas-mono);
|
| 1624 |
+
font-size: 10px;
|
| 1625 |
+
color: var(--canvas-text-4);
|
| 1626 |
+
}
|
| 1627 |
+
|
| 1628 |
+
/* The page itself */
|
| 1629 |
+
.canvas-page-with-sidebar .notion-page-wrap {
|
| 1630 |
+
display: flex;
|
| 1631 |
+
justify-content: center;
|
| 1632 |
+
}
|
| 1633 |
+
.canvas-page-with-sidebar .notion-page {
|
| 1634 |
+
background: var(--canvas-surface);
|
| 1635 |
+
width: 100%;
|
| 1636 |
+
max-width: 760px;
|
| 1637 |
+
padding: 60px 80px;
|
| 1638 |
+
border-radius: 4px;
|
| 1639 |
+
color: var(--canvas-text);
|
| 1640 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 1641 |
+
}
|
| 1642 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .notion-page {
|
| 1643 |
+
background: #ffffff;
|
| 1644 |
+
border: 1px solid rgba(15, 15, 15, 0.06);
|
| 1645 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
| 1646 |
+
}
|
| 1647 |
+
.canvas-page-with-sidebar .notion-page.serif {
|
| 1648 |
+
font-family: 'Georgia', 'Times New Roman', serif;
|
| 1649 |
+
}
|
| 1650 |
+
.canvas-page-with-sidebar .notion-page-wrap.paper .notion-page {
|
| 1651 |
+
background: #fdfbf7;
|
| 1652 |
+
}
|
| 1653 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .notion-page-wrap.paper .notion-page {
|
| 1654 |
+
background: #fdfbf7;
|
| 1655 |
+
}
|
| 1656 |
+
.canvas-page-with-sidebar .notion-page-title {
|
| 1657 |
+
font-size: 40px;
|
| 1658 |
+
font-weight: 700;
|
| 1659 |
+
margin: 0 0 6px;
|
| 1660 |
+
letter-spacing: -0.025em;
|
| 1661 |
+
line-height: 1.15;
|
| 1662 |
+
color: var(--canvas-text);
|
| 1663 |
+
}
|
| 1664 |
+
.canvas-page-with-sidebar .notion-page-meta {
|
| 1665 |
+
font-size: 12.5px;
|
| 1666 |
+
color: var(--canvas-text-3);
|
| 1667 |
+
margin: 0 0 36px;
|
| 1668 |
+
}
|
| 1669 |
+
|
| 1670 |
+
.canvas-page-with-sidebar .notion-block {
|
| 1671 |
+
margin: 24px 0;
|
| 1672 |
+
position: relative;
|
| 1673 |
+
}
|
| 1674 |
+
.canvas-page-with-sidebar .notion-h2 {
|
| 1675 |
+
font-size: 24px;
|
| 1676 |
+
font-weight: 700;
|
| 1677 |
+
letter-spacing: -0.015em;
|
| 1678 |
+
margin: 0 0 8px;
|
| 1679 |
+
color: var(--canvas-text);
|
| 1680 |
+
}
|
| 1681 |
+
.canvas-page-with-sidebar .notion-h2.serif {
|
| 1682 |
+
font-size: 16px;
|
| 1683 |
+
text-transform: uppercase;
|
| 1684 |
+
letter-spacing: 0.06em;
|
| 1685 |
+
font-weight: 700;
|
| 1686 |
+
}
|
| 1687 |
+
.canvas-page-with-sidebar .notion-hint {
|
| 1688 |
+
font-size: 13.5px;
|
| 1689 |
+
color: var(--canvas-text-4);
|
| 1690 |
+
font-style: italic;
|
| 1691 |
+
margin: 4px 0 6px;
|
| 1692 |
+
}
|
| 1693 |
+
|
| 1694 |
+
/* The textarea, styled as Notion body text */
|
| 1695 |
+
.canvas-page-with-sidebar .notion-text {
|
| 1696 |
+
display: block;
|
| 1697 |
+
width: 100%;
|
| 1698 |
+
background: transparent;
|
| 1699 |
+
border: none;
|
| 1700 |
+
outline: none;
|
| 1701 |
+
resize: none;
|
| 1702 |
+
padding: 4px 0;
|
| 1703 |
+
margin: 0;
|
| 1704 |
+
font-family: inherit;
|
| 1705 |
+
font-size: 16px;
|
| 1706 |
+
line-height: 1.6;
|
| 1707 |
+
color: var(--canvas-text);
|
| 1708 |
+
overflow: hidden;
|
| 1709 |
+
min-height: 28px;
|
| 1710 |
+
}
|
| 1711 |
+
.canvas-page-with-sidebar .notion-text.serif {
|
| 1712 |
+
font-family: 'Georgia', 'Times New Roman', serif;
|
| 1713 |
+
font-size: 14.5px;
|
| 1714 |
+
line-height: 1.7;
|
| 1715 |
+
}
|
| 1716 |
+
.canvas-page-with-sidebar .notion-text::placeholder {
|
| 1717 |
+
color: var(--canvas-text-4);
|
| 1718 |
+
}
|
| 1719 |
+
.canvas-page-with-sidebar .notion-text:focus {
|
| 1720 |
+
background: rgba(0, 0, 0, 0.02);
|
| 1721 |
+
border-radius: 3px;
|
| 1722 |
+
padding: 4px 6px;
|
| 1723 |
+
margin: 0 -6px;
|
| 1724 |
+
}
|
| 1725 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .notion-text:focus {
|
| 1726 |
+
background: rgba(0, 0, 0, 0.025);
|
| 1727 |
+
}
|
| 1728 |
+
|
| 1729 |
+
/* Inline meta (check pills + word count) sits below the block */
|
| 1730 |
+
.canvas-page-with-sidebar .notion-block-meta {
|
| 1731 |
+
margin-top: 10px;
|
| 1732 |
+
display: flex;
|
| 1733 |
+
flex-wrap: wrap;
|
| 1734 |
+
gap: 6px;
|
| 1735 |
+
}
|
| 1736 |
+
|
| 1737 |
+
/* Notion-style callout box for AI suggestions */
|
| 1738 |
+
.canvas-page-with-sidebar .notion-callout {
|
| 1739 |
+
margin-top: 12px;
|
| 1740 |
+
background: rgba(99, 102, 241, 0.06);
|
| 1741 |
+
border-radius: 4px;
|
| 1742 |
+
padding: 14px 16px;
|
| 1743 |
+
display: flex;
|
| 1744 |
+
gap: 10px;
|
| 1745 |
+
font-size: 13.5px;
|
| 1746 |
+
line-height: 1.55;
|
| 1747 |
+
color: var(--canvas-text);
|
| 1748 |
+
}
|
| 1749 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .notion-callout {
|
| 1750 |
+
background: rgba(99, 102, 241, 0.07);
|
| 1751 |
+
}
|
| 1752 |
+
.canvas-page-with-sidebar .notion-callout > svg {
|
| 1753 |
+
color: var(--canvas-accent);
|
| 1754 |
+
flex-shrink: 0;
|
| 1755 |
+
margin-top: 2px;
|
| 1756 |
+
}
|
| 1757 |
+
.canvas-page-with-sidebar .notion-callout-label {
|
| 1758 |
+
font-size: 11px;
|
| 1759 |
+
text-transform: uppercase;
|
| 1760 |
+
letter-spacing: 0.06em;
|
| 1761 |
+
color: var(--canvas-accent);
|
| 1762 |
+
font-weight: 600;
|
| 1763 |
+
margin-bottom: 2px;
|
| 1764 |
+
}
|
| 1765 |
+
|
| 1766 |
+
/* Export dropdown — uses native <details> for zero-state-management */
|
| 1767 |
+
.canvas-page-with-sidebar .canvas-export-menu summary {
|
| 1768 |
+
list-style: none;
|
| 1769 |
+
}
|
| 1770 |
+
.canvas-page-with-sidebar .canvas-export-menu summary::-webkit-details-marker {
|
| 1771 |
+
display: none;
|
| 1772 |
+
}
|
| 1773 |
+
.canvas-page-with-sidebar .canvas-export-menu[open] summary {
|
| 1774 |
+
filter: brightness(1.05);
|
| 1775 |
+
}
|
| 1776 |
+
.canvas-page-with-sidebar .canvas-export-menu-list {
|
| 1777 |
+
position: absolute;
|
| 1778 |
+
top: calc(100% + 4px);
|
| 1779 |
+
right: 0;
|
| 1780 |
+
background: var(--canvas-surface);
|
| 1781 |
+
border: 1px solid var(--canvas-border-2);
|
| 1782 |
+
border-radius: 6px;
|
| 1783 |
+
box-shadow: var(--canvas-shadow-lg);
|
| 1784 |
+
padding: 4px;
|
| 1785 |
+
min-width: 180px;
|
| 1786 |
+
display: flex;
|
| 1787 |
+
flex-direction: column;
|
| 1788 |
+
z-index: 30;
|
| 1789 |
+
}
|
| 1790 |
+
.canvas-page-with-sidebar .canvas-export-menu-list button {
|
| 1791 |
+
text-align: left;
|
| 1792 |
+
background: transparent;
|
| 1793 |
+
border: none;
|
| 1794 |
+
padding: 7px 10px;
|
| 1795 |
+
border-radius: 4px;
|
| 1796 |
+
font-size: 13px;
|
| 1797 |
+
cursor: pointer;
|
| 1798 |
+
color: var(--canvas-text);
|
| 1799 |
+
}
|
| 1800 |
+
.canvas-page-with-sidebar .canvas-export-menu-list button:hover {
|
| 1801 |
+
background: rgba(255, 255, 255, 0.05);
|
| 1802 |
+
}
|
| 1803 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .canvas-export-menu-list button:hover {
|
| 1804 |
+
background: rgba(0, 0, 0, 0.05);
|
| 1805 |
+
}
|
| 1806 |
+
|
| 1807 |
+
/* Old paper/doc preview styles can stay below for the slides bottom-info case */
|
| 1808 |
+
|
| 1809 |
+
/* Responsive breakpoints for paper/document mode */
|
| 1810 |
+
@media (max-width: 1280px) {
|
| 1811 |
+
.canvas-page-with-sidebar .deliverable-grid { grid-template-columns: 160px minmax(0, 1fr); }
|
| 1812 |
+
.canvas-page-with-sidebar .deliverable-grid > .deliverable-insertables { display: none; }
|
| 1813 |
+
}
|
| 1814 |
+
@media (max-width: 768px) {
|
| 1815 |
+
.canvas-page-with-sidebar .deliverable-grid { grid-template-columns: 1fr; }
|
| 1816 |
+
.canvas-page-with-sidebar .doc-page,
|
| 1817 |
+
.canvas-page-with-sidebar .paper-page { padding: 32px 24px; }
|
| 1818 |
+
}
|
| 1819 |
+
|
| 1820 |
+
/* Insertable row in the right rail of Deliverables */
|
| 1821 |
+
.canvas-page-with-sidebar .canvas-insert-row {
|
| 1822 |
+
display: flex;
|
| 1823 |
+
align-items: center;
|
| 1824 |
+
gap: 8px;
|
| 1825 |
+
padding: 6px 8px;
|
| 1826 |
+
background: transparent;
|
| 1827 |
+
border: none;
|
| 1828 |
+
border-radius: 4px;
|
| 1829 |
+
text-align: left;
|
| 1830 |
+
cursor: pointer;
|
| 1831 |
+
font-family: inherit;
|
| 1832 |
+
color: var(--canvas-text);
|
| 1833 |
+
transition: background .12s;
|
| 1834 |
+
}
|
| 1835 |
+
.canvas-page-with-sidebar .canvas-insert-row:hover {
|
| 1836 |
+
background: rgba(255, 255, 255, 0.05);
|
| 1837 |
+
}
|
| 1838 |
+
.canvas-page-with-sidebar[data-canvas-theme="light"] .canvas-insert-row:hover {
|
| 1839 |
+
background: rgba(0, 0, 0, 0.04);
|
| 1840 |
+
}
|
| 1841 |
+
|
| 1842 |
+
/* Cross-widget drop overlay — shows over the target widget body during drag */
|
| 1843 |
+
.canvas-page-with-sidebar .canvas-drop-overlay {
|
| 1844 |
+
position: absolute;
|
| 1845 |
+
inset: -4px;
|
| 1846 |
+
background: var(--canvas-accent-glow);
|
| 1847 |
+
border: 2px dashed var(--canvas-accent);
|
| 1848 |
+
border-radius: 9px;
|
| 1849 |
+
display: flex;
|
| 1850 |
+
align-items: center;
|
| 1851 |
+
justify-content: center;
|
| 1852 |
+
gap: 8px;
|
| 1853 |
+
font-size: 13px;
|
| 1854 |
+
font-weight: 600;
|
| 1855 |
+
color: var(--canvas-accent);
|
| 1856 |
+
z-index: 5;
|
| 1857 |
+
pointer-events: none;
|
| 1858 |
+
}
|
| 1859 |
+
.canvas-page-with-sidebar .canvas-drop-active { outline: none; }
|
| 1860 |
+
|
| 1861 |
/* Compact markdown rendering inside note rows */
|
| 1862 |
.canvas-page-with-sidebar .canvas-md p { margin: 0 0 4px; }
|
| 1863 |
.canvas-page-with-sidebar .canvas-md p:last-child { margin-bottom: 0; }
|