From 2336ac806210c83ae91ab37ed4230e56a13036ed Mon Sep 17 00:00:00 2001
From: Kobe Attias
Date: Sun, 24 May 2026 20:38:17 -0400
Subject: [PATCH 1/5] Support docx, md converting + uploading via notebook
---
app/notebook/api/convert/route.ts | 36 ++
components/Editor/extensions/Ai/Ai.ts | 6 +-
.../extensions/TrailingNode/trailing-node.ts | 5 +
components/Editor/lib/convert.ts | 244 ++++++++++
components/Notebook/LeftSidebar/index.tsx | 338 +++++++------
components/Notebook/NoteCreationModal.tsx | 450 ++++++++++++++++++
package-lock.json | 15 +
package.json | 1 +
8 files changed, 943 insertions(+), 152 deletions(-)
create mode 100644 app/notebook/api/convert/route.ts
create mode 100644 components/Editor/lib/convert.ts
create mode 100644 components/Notebook/NoteCreationModal.tsx
diff --git a/app/notebook/api/convert/route.ts b/app/notebook/api/convert/route.ts
new file mode 100644
index 000000000..74facb1d9
--- /dev/null
+++ b/app/notebook/api/convert/route.ts
@@ -0,0 +1,36 @@
+import jsonwebtoken from 'jsonwebtoken';
+import { getServerSession } from 'next-auth/next';
+import { authOptions } from '@/app/api/auth/[...nextauth]/auth.config';
+
+const JWT_SECRET = process.env?.TIPTAP_CONVERT_SECRET;
+
+// Tokens expire after 15 minutes. Long enough to cover any reasonable docx
+// upload (Tiptap's Convert service is synchronous and usually returns in
+// 2-10s), short enough that a leaked token has a narrow useful window.
+const JWT_EXPIRES_IN = '15m';
+
+export async function POST(): Promise {
+ if (!JWT_SECRET) {
+ return new Response(
+ JSON.stringify({
+ error: 'No convert token provided, please set TIPTAP_CONVERT_SECRET in your environment',
+ }),
+ { status: 403 }
+ );
+ }
+
+ // The route is already gated by next-auth middleware (matcher
+ // `/notebook/api/:path*`), so an anonymous request can't reach here. We
+ // still pull the session to bind the JWT to a specific user via the `sub`
+ // claim, which gives us audit attribution if Tiptap usage ever spikes.
+ const session = await getServerSession(authOptions);
+ if (!session?.userId) {
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
+ }
+
+ const jwt = jsonwebtoken.sign({ sub: session.userId }, JWT_SECRET, {
+ expiresIn: JWT_EXPIRES_IN,
+ });
+
+ return new Response(JSON.stringify({ token: jwt }));
+}
diff --git a/components/Editor/extensions/Ai/Ai.ts b/components/Editor/extensions/Ai/Ai.ts
index abf7c72b3..19355fa67 100644
--- a/components/Editor/extensions/Ai/Ai.ts
+++ b/components/Editor/extensions/Ai/Ai.ts
@@ -1,5 +1,9 @@
import AiExtension from '@tiptap-pro/extension-ai';
-const TIPTAP_AI_APP_ID = process.env.NEXT_PUBLIC_TIPTAP_AI_APP_ID;
+
+// Account-wide Tiptap Cloud App ID. The same value is used by Content AI,
+// Convert, and Collab — each product has its own JWT secret, but they all
+// authenticate against the same account-scoped App ID.
+const TIPTAP_AI_APP_ID = process.env.NEXT_PUBLIC_TIPTAP_APP_ID;
const TIPTAP_AI_BASE_URL =
process.env.NEXT_PUBLIC_TIPTAP_AI_BASE_URL || 'https://api.tiptap.dev/v1/ai';
diff --git a/components/Editor/extensions/TrailingNode/trailing-node.ts b/components/Editor/extensions/TrailingNode/trailing-node.ts
index 8b2bf9fd3..5ae3632aa 100644
--- a/components/Editor/extensions/TrailingNode/trailing-node.ts
+++ b/components/Editor/extensions/TrailingNode/trailing-node.ts
@@ -3,6 +3,11 @@ import { Plugin, PluginKey } from '@tiptap/pm/state';
// @ts-ignore
function nodeEqualsType({ types, node }) {
+ // Guard against a null `node` (state.init can see a doc whose lastChild is
+ // null, e.g. when the editor is constructed with empty content). Returning
+ // false here means the plugin will append the trailing node, which is the
+ // correct behavior for an empty document.
+ if (!node) return false;
return (Array.isArray(types) && types.includes(node.type)) || node.type === types;
}
diff --git a/components/Editor/lib/convert.ts b/components/Editor/lib/convert.ts
new file mode 100644
index 000000000..2aa868bd4
--- /dev/null
+++ b/components/Editor/lib/convert.ts
@@ -0,0 +1,244 @@
+'use client';
+
+import { Editor, JSONContent } from '@tiptap/core';
+import type { AnyExtension } from '@tiptap/core';
+import { TextAlign } from '@tiptap/extension-text-align';
+import { History } from '@tiptap/extension-history';
+import { Import } from '@tiptap-pro/extension-import';
+
+import { ExtensionKit } from '@/components/Editor/extensions/extension-kit';
+import { getDocumentTitle } from '@/components/Editor/lib/utils/documentTitle';
+
+/**
+ * Supported source formats for the Tiptap Cloud Convert import flow.
+ * Mirrors the legacy `@tiptap-pro/extension-import` `formatMap`:
+ * - docx: Microsoft Word (routes to the `/import-docx` endpoint via the
+ * `experimentalDocxImport` flag).
+ * - odt: OpenDocument Text (LibreOffice, Google Docs export).
+ * - md: CommonMark / GitHub Flavored Markdown. We pass `format: 'gfm'`
+ * for any `.md` file so tables, task lists, and strikethrough parse
+ * correctly — the GFM superset is backward-compatible with CommonMark
+ * for everything else, so there's no downside.
+ */
+export type SupportedImportFormat = 'docx' | 'odt' | 'md';
+
+const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+const ODT_MIME = 'application/vnd.oasis.opendocument.text';
+const MD_MIMES = new Set(['text/markdown', 'text/x-markdown']);
+
+/**
+ * Server-enforced upload size cap (25 MB). The modal's file picker also
+ * blocks uploads larger than this, but we re-check here so the limit holds
+ * even if `importDocumentToTiptap` is called from another caller that
+ * doesn't run the modal's validation.
+ */
+const MAX_IMPORT_SIZE = 25 * 1024 * 1024;
+
+/**
+ * Resolve a file to one of our supported import formats, preferring MIME
+ * type when the OS provided one and falling back to the filename extension.
+ * Returns null for unsupported inputs so callers can produce a clear error.
+ */
+export const detectImportFormat = (file: File): SupportedImportFormat | null => {
+ const name = file.name.toLowerCase();
+
+ if (file.type === DOCX_MIME || name.endsWith('.docx')) return 'docx';
+ if (file.type === ODT_MIME || name.endsWith('.odt')) return 'odt';
+ if (MD_MIMES.has(file.type) || name.endsWith('.md') || name.endsWith('.markdown')) return 'md';
+
+ return null;
+};
+
+/**
+ * Strip the source-format extension from a filename so it can be used as a
+ * fallback note title. Only strips the extensions we actually accept so we
+ * don't accidentally mangle filenames like `data.archive.docx`.
+ */
+const stripImportExtension = (filename: string): string =>
+ filename.replace(/\.(docx|odt|md|markdown)$/i, '').trim();
+
+/**
+ * Fetch a fresh convert JWT for each import. Tokens expire after 15 minutes
+ * (see `/notebook/api/convert`), so a single fetch per import is the
+ * simplest correct behavior. Caching would force us to manage expiry
+ * client-side; not worth it given each import already pays the round-trip.
+ */
+const fetchConvertToken = async (): Promise => {
+ const response = await fetch('/notebook/api/convert', { method: 'POST' });
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch convert token (${response.status}). Is TIPTAP_CONVERT_SECRET set?`
+ );
+ }
+ const body = (await response.json()) as { token?: string; error?: string };
+ if (!body.token) {
+ throw new Error(body.error || 'Convert token response did not include a token.');
+ }
+ return body.token;
+};
+
+export interface DocumentImportResult {
+ /** TipTap JSON document the live editor can render directly. */
+ json: JSONContent;
+ /** Serialized HTML for the legacy `content` field on the note record. */
+ html: string;
+ /** Plain text for search indexing and excerpts. */
+ plainText: string;
+ /** Heuristic title pulled from the document (first heading, falls back to filename). */
+ title: string;
+ /** The format the source file was detected as. */
+ format: SupportedImportFormat;
+}
+
+/**
+ * Convert a supported document (.docx, .odt, .md) to TipTap content by
+ * spinning up a headless editor that uses the same extension kit as the
+ * live note editor. The throwaway editor is destroyed before the function
+ * returns.
+ *
+ * Image handling: this v1 does not pass `imageUploadCallbackUrl` to the
+ * Import extension, so embedded images are stripped on import. Wiring image
+ * upload requires a public Next.js callback route plus either a server-side
+ * AWS SDK or a shared-secret Django endpoint. Tracked as a follow-up.
+ */
+export const importDocumentToTiptap = async (
+ file: File,
+ options: { fallbackTitle?: string } = {}
+): Promise => {
+ // The Tiptap Cloud App ID is account-wide and shared across products
+ // (Content AI, Convert, Collab). Each product has its own JWT secret on
+ // the server, but the App ID is the same value used by `Ai.ts`.
+ const appId = process.env.NEXT_PUBLIC_TIPTAP_APP_ID;
+ if (!appId) {
+ throw new Error(
+ 'NEXT_PUBLIC_TIPTAP_APP_ID is not set. Add it to your environment to enable document import.'
+ );
+ }
+
+ const format = detectImportFormat(file);
+ if (!format) {
+ throw new Error('Unsupported file type. Please upload a .docx, .odt, or .md document.');
+ }
+
+ if (file.size > MAX_IMPORT_SIZE) {
+ throw new Error(
+ `Document is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). The maximum is ${MAX_IMPORT_SIZE / 1024 / 1024} MB.`
+ );
+ }
+
+ const token = await fetchConvertToken();
+
+ // The headless import editor uses the *default* Document node (which
+ // accepts `block+`) rather than the live editor's `heading block+` custom
+ // document. Two reasons:
+ // 1) Many imported documents don't start with a heading (especially
+ // markdown). Forcing `heading block+` during import would cause
+ // ProseMirror to coerce or drop the leading content, silently
+ // corrupting the imported document.
+ // 2) The live editor's schema is enforced at the editing layer; the
+ // stored JSON just needs to be coercible into it. We handle that
+ // coercion explicitly below by prepending a title heading if needed.
+ const extensions: AnyExtension[] = [
+ ...ExtensionKit({}),
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
+ History.configure({ depth: 100 }),
+ Import.configure({
+ appId,
+ token,
+ // experimentalDocxImport is required on legacy 2.x to opt into the
+ // DOCX-specific endpoint. It's a no-op for ODT/Markdown (the
+ // extension only takes the docx branch when the file MIME matches),
+ // so we can safely leave it on for all imports.
+ experimentalDocxImport: true,
+ }),
+ ];
+
+ // Initial content is a single paragraph rather than `{ content: [] }`.
+ // The TrailingNode plugin's state.init reads doc.lastChild, which is null
+ // for a truly empty doc and crashes the plugin. A single paragraph is the
+ // simplest valid seed that satisfies every plugin's invariants.
+ const editor = new Editor({
+ extensions,
+ editable: false,
+ content: { type: 'doc', content: [{ type: 'paragraph' }] },
+ });
+
+ try {
+ const json = await new Promise((resolve, reject) => {
+ let settled = false;
+
+ // Safety net: surface a timeout rather than hanging the UI if onImport
+ // never fires (network outage, hung extension, etc).
+ const timeoutId = setTimeout(() => {
+ if (settled) return;
+ settled = true;
+ reject(new Error('Document import timed out after 60 seconds.'));
+ }, 60_000);
+
+ editor
+ .chain()
+ .import({
+ file,
+ // `format: 'gfm'` only matters for markdown (it flips a query
+ // flag on the Convert endpoint enabling GFM extensions). For
+ // docx/odt the extension ignores it.
+ ...(format === 'md' ? ({ format: 'gfm' } as const) : {}),
+ onImport(context) {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timeoutId);
+ if (context.error) {
+ reject(context.error);
+ return;
+ }
+ // setEditorContent writes the normalized content into the
+ // throwaway editor; reading getJSON afterwards captures it
+ // shaped against our local schema (unknown nodes filtered).
+ context.setEditorContent();
+ resolve(editor.getJSON());
+ },
+ })
+ .run();
+ });
+
+ // The live editor requires `heading block+`. If the imported doc doesn't
+ // start with a heading, prepend one derived from the filename so the
+ // result is loadable in the live editor without ProseMirror dropping or
+ // mangling the leading content.
+ const fallbackTitle = stripImportExtension(file.name) || options.fallbackTitle || 'Untitled';
+ const normalizedJson = ensureLeadingHeading(json, fallbackTitle);
+
+ const html = editor.getHTML();
+ const plainText = editor.getText();
+ const headingTitle = getDocumentTitle(normalizedJson);
+ const title = (headingTitle && headingTitle.trim()) || fallbackTitle;
+
+ return { json: normalizedJson, html, plainText, title, format };
+ } finally {
+ editor.destroy();
+ }
+};
+
+/**
+ * Ensure the imported document starts with a level-1 heading. If the first
+ * block is already a heading we leave it alone. Otherwise we synthesize one
+ * from `fallbackTitle` so the resulting JSON satisfies the live editor's
+ * `heading block+` schema constraint.
+ */
+const ensureLeadingHeading = (doc: JSONContent, fallbackTitle: string): JSONContent => {
+ const firstBlock = doc.content?.[0];
+ if (firstBlock?.type === 'heading') {
+ return doc;
+ }
+ return {
+ ...doc,
+ content: [
+ {
+ type: 'heading',
+ attrs: { textAlign: 'left', level: 1 },
+ content: [{ type: 'text', text: fallbackTitle }],
+ },
+ ...(doc.content ?? []),
+ ],
+ };
+};
diff --git a/components/Notebook/LeftSidebar/index.tsx b/components/Notebook/LeftSidebar/index.tsx
index 52fa0ed8a..154060c50 100644
--- a/components/Notebook/LeftSidebar/index.tsx
+++ b/components/Notebook/LeftSidebar/index.tsx
@@ -3,15 +3,12 @@
import { NoteList } from '@/components/Notebook/LeftSidebar/NoteList';
import { OrganizationSwitcher } from '@/components/Notebook/LeftSidebar/OrganizationSwitcher';
import { SidebarSection } from '@/components/Notebook/LeftSidebar/SidebarSection';
-import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu';
import { Button } from '@/components/ui/Button';
import { useOrganizationContext } from '@/contexts/OrganizationContext';
-import { Plus, Lock, Loader2, File, FileText, type LucideIcon } from 'lucide-react';
-import { FundingIcon } from '@/components/ui/icons/FundingIcon';
-import Icon from '@/components/ui/icons/Icon';
+import { Plus, Lock, Loader2, FileText, type LucideIcon } from 'lucide-react';
import { Organization } from '@/types/organization';
import { useRouter } from 'next/navigation';
-import { useCallback, useTransition } from 'react';
+import { useCallback, useState, useTransition } from 'react';
import { useNoteContent, useCreateNote } from '@/hooks/useNote';
import { getInitialContent } from '@/components/Editor/lib/data/initialContent';
import {
@@ -22,33 +19,48 @@ import toast from 'react-hot-toast';
import { useNotebookContext } from '@/contexts/NotebookContext';
import grantTemplate from '@/components/Editor/lib/data/grantTemplate';
import proposalTemplate from '@/components/Editor/lib/data/proposalTemplate';
+import { NoteCreationModal, NoteCreationType } from '@/components/Notebook/NoteCreationModal';
+import { importDocumentToTiptap } from '@/components/Editor/lib/convert';
-const TEMPLATE_ITEMS = [
- {
- id: 'preregistration' as const,
- title: 'Proposal',
- description: 'Crowdfund your research',
- icon: ,
- },
- {
- id: 'grant' as const,
- title: 'Funding Opportunity',
- description: 'Fund specific research you care about',
- icon: ,
- },
- {
- id: 'research' as const,
- title: 'Preprint',
- description: 'Publish your research as a preprint',
- icon: ,
- },
- {
- id: 'empty' as const,
- title: 'Empty',
- description: 'Start with a blank page',
- icon: ,
- },
-];
+type Grouping = 'workspace' | 'private';
+
+type TemplateId = 'preregistration' | 'grant' | 'research';
+
+// Map our internal template ids onto the Django document_type strings the
+// publishing form and downstream APIs key off of. Mirrors the mapping used in
+// `app/notebook/[orgSlug]/page.tsx`.
+const TEMPLATE_TO_DOCUMENT_TYPE: Record = {
+ preregistration: 'PREREGISTRATION',
+ grant: 'GRANT',
+ research: 'DISCUSSION',
+};
+
+const EMPTY_TEMPLATE = {
+ type: 'doc' as const,
+ content: [
+ {
+ type: 'heading',
+ attrs: { textAlign: 'left', level: 1 },
+ content: [{ type: 'text', text: 'Untitled' }],
+ },
+ {
+ type: 'paragraph',
+ attrs: { class: null, textAlign: 'left' },
+ },
+ ],
+};
+
+const getTemplateContent = (templateId: TemplateId) => {
+ switch (templateId) {
+ case 'grant':
+ return grantTemplate;
+ case 'preregistration':
+ return proposalTemplate;
+ case 'research':
+ default:
+ return getInitialContent('research');
+ }
+};
export const LeftSidebar = () => {
const router = useRouter();
@@ -64,6 +76,13 @@ export const LeftSidebar = () => {
} = useOrganizationContext();
const { notes, isLoading: isLoadingNotes, refreshNotes } = useNotebookContext();
+ // Which section's "+" was clicked, or null when the modal is closed.
+ const [activeModalGrouping, setActiveModalGrouping] = useState(null);
+ // Distinct from createNote/updateNote loading because the conversion call
+ // happens before either of those fire — we want the modal to show "Importing"
+ // for the full duration including the network round-trip.
+ const [isImporting, setIsImporting] = useState(false);
+
const handleOrgSelect = useCallback(
async (org: Organization) => {
setSelectedOrg(org);
@@ -77,62 +96,39 @@ export const LeftSidebar = () => {
toast.error('Failed to switch organization. Please try again.');
}
},
- [router, startTransition]
+ [router, setSelectedOrg]
);
- const handleTemplateSelect = useCallback(
- async (
- type: 'workspace' | 'private',
- template: 'research' | 'grant' | 'preregistration' | 'empty'
- ) => {
+ const createNoteWithContent = useCallback(
+ async ({
+ grouping,
+ template,
+ templateContent,
+ documentType,
+ }: {
+ grouping: Grouping;
+ template: TemplateId | 'empty';
+ templateContent: ReturnType | typeof EMPTY_TEMPLATE;
+ documentType?: string;
+ }) => {
if (!selectedOrg) return;
try {
- let contentTemplate;
- switch (template) {
- case 'research':
- contentTemplate = getInitialContent('research');
- break;
- case 'grant':
- contentTemplate = grantTemplate;
- break;
- case 'preregistration':
- contentTemplate = proposalTemplate;
- break;
- case 'empty':
- contentTemplate = {
- type: 'doc',
- content: [
- {
- type: 'heading',
- attrs: { textAlign: 'left', level: 1 },
- content: [{ type: 'text', text: 'Untitled' }],
- },
- {
- type: 'paragraph',
- attrs: { class: null, textAlign: 'left' },
- },
- ],
- };
- break;
- default:
- contentTemplate = getInitialContent('research');
- break;
- }
-
const newNote = await createNote({
- title: getDocumentTitle(contentTemplate) || 'Untitled',
- grouping: type.toUpperCase() as 'WORKSPACE' | 'PRIVATE',
+ title: getDocumentTitle(templateContent) || 'Untitled',
+ grouping: grouping.toUpperCase() as 'WORKSPACE' | 'PRIVATE',
organizationSlug: selectedOrg.slug,
+ documentType,
});
await updateNoteContent({
note: newNote.id,
- fullJson: JSON.stringify(contentTemplate),
- plainText: getTemplatePlainText(contentTemplate),
+ fullJson: JSON.stringify(templateContent),
+ plainText: getTemplatePlainText(templateContent),
});
refreshNotes();
+ setActiveModalGrouping(null);
router.push(`/notebook/${selectedOrg.slug}/${newNote.id}?template=${template}`);
} catch (error) {
console.error('Error creating note:', error);
@@ -141,97 +137,121 @@ export const LeftSidebar = () => {
});
}
},
- [createNote, updateNoteContent, router, selectedOrg, refreshNotes]
+ [createNote, updateNoteContent, refreshNotes, router, selectedOrg]
+ );
+
+ const handleCreateFromTemplate = useCallback(
+ async (grouping: Grouping, type: TemplateId) => {
+ await createNoteWithContent({
+ grouping,
+ template: type,
+ templateContent: getTemplateContent(type),
+ documentType: TEMPLATE_TO_DOCUMENT_TYPE[type],
+ });
+ },
+ [createNoteWithContent]
+ );
+
+ const handleCreateBlank = useCallback(
+ async (grouping: Grouping) => {
+ await createNoteWithContent({
+ grouping,
+ template: 'empty',
+ templateContent: EMPTY_TEMPLATE,
+ });
+ },
+ [createNoteWithContent]
+ );
+
+ const handleCreateFromUpload = useCallback(
+ async (grouping: Grouping, { file, type }: { file: File; type: NoteCreationType }) => {
+ if (!selectedOrg) return;
+ setIsImporting(true);
+ try {
+ const result = await importDocumentToTiptap(file);
+ const documentType =
+ type === 'other' ? undefined : TEMPLATE_TO_DOCUMENT_TYPE[type as TemplateId];
+
+ const newNote = await createNote({
+ title: result.title,
+ grouping: grouping.toUpperCase() as 'WORKSPACE' | 'PRIVATE',
+ organizationSlug: selectedOrg.slug,
+ documentType,
+ });
+
+ await updateNoteContent({
+ note: newNote.id,
+ fullSrc: result.html,
+ fullJson: JSON.stringify(result.json),
+ plainText: result.plainText,
+ });
+
+ refreshNotes();
+ setActiveModalGrouping(null);
+ // No ?template= query — the import gave us real content, not a scaffold.
+ router.push(`/notebook/${selectedOrg.slug}/${newNote.id}`);
+ } catch (error) {
+ console.error('Error importing document:', error);
+ const message =
+ error instanceof Error
+ ? error.message
+ : 'Failed to import document. Please try a different file.';
+ toast.error(message, { style: { width: '320px' } });
+ } finally {
+ setIsImporting(false);
+ }
+ },
+ [createNote, updateNoteContent, refreshNotes, router, selectedOrg]
);
- const isProcessing = isCreatingNote || isUpdatingContent;
+ const isProcessing = isCreatingNote || isUpdatingContent || isImporting;
const hasWorkspaceNotes = notes?.some((n) => n.access === 'WORKSPACE' || n.access === 'SHARED');
const hasPrivateNotes = notes?.some((n) => n.access === 'PRIVATE');
- const renderTemplateMenu = (type: 'workspace' | 'private', triggerLabel?: string) => (
-
-
- {triggerLabel}
-
+ const openModalFor = (grouping: Grouping) => () => setActiveModalGrouping(grouping);
+
+ const renderAddButton = (grouping: Grouping, triggerLabel?: string) =>
+ triggerLabel ? (
+
+
+ {triggerLabel}
+
+ ) : (
+
+ {isProcessing ? (
+
) : (
-
- {isProcessing ? (
-
- ) : (
-
- )}
-
- )
- }
- align="start"
- className="w-[340px] p-2"
- >
-
-
-
-
- Select Template
-
-
-
- {TEMPLATE_ITEMS.map((item) => (
-
handleTemplateSelect(type, item.id)}
- className="w-full px-2"
- disabled={isProcessing}
- >
-
-
-
- {isProcessing ? (
-
- ) : (
- item.icon
- )}
-
-
-
-
- {item.title}
-
-
{item.description}
-
-
-
- ))}
-
-
-
-
- );
+
+ )}
+
+ );
const renderEmptyState = ({
icon: StateIcon,
title,
subtitle,
buttonLabel,
- type,
+ grouping,
}: {
icon: LucideIcon;
title: string;
subtitle: string;
buttonLabel: string;
- type: 'workspace' | 'private';
+ grouping: Grouping;
}) => (
@@ -239,7 +259,7 @@ export const LeftSidebar = () => {
{title}
{subtitle}
- {renderTemplateMenu(type, buttonLabel)}
+ {renderAddButton(grouping, buttonLabel)}
);
@@ -258,7 +278,7 @@ export const LeftSidebar = () => {
title="Workspace"
icon={FileText}
iconPosition="after"
- action={renderTemplateMenu('workspace')}
+ action={renderAddButton('workspace')}
>
{hasWorkspaceNotes || isLoadingNotes || isLoadingOrgs ? (
{
title: 'No notes yet',
subtitle: 'Create your first note to get started',
buttonLabel: 'Add New Note',
- type: 'workspace',
+ grouping: 'workspace',
})
)}
@@ -283,7 +303,7 @@ export const LeftSidebar = () => {
title="Private"
icon={Lock}
iconPosition="after"
- action={renderTemplateMenu('private')}
+ action={renderAddButton('private')}
>
{hasPrivateNotes || isLoadingNotes || isLoadingOrgs ? (
{
title: 'No private notes yet',
subtitle: 'Private notes are only visible to you',
buttonLabel: 'Add Private Note',
- type: 'private',
+ grouping: 'private',
})
)}
+
+ {
+ if (!isProcessing) setActiveModalGrouping(null);
+ }}
+ grouping={activeModalGrouping ?? 'workspace'}
+ onCreateFromTemplate={(type) =>
+ handleCreateFromTemplate(activeModalGrouping ?? 'workspace', type)
+ }
+ onCreateBlank={() => handleCreateBlank(activeModalGrouping ?? 'workspace')}
+ onCreateFromUpload={(params) =>
+ handleCreateFromUpload(activeModalGrouping ?? 'workspace', params)
+ }
+ isProcessing={isProcessing}
+ />
);
};
diff --git a/components/Notebook/NoteCreationModal.tsx b/components/Notebook/NoteCreationModal.tsx
new file mode 100644
index 000000000..972ba1277
--- /dev/null
+++ b/components/Notebook/NoteCreationModal.tsx
@@ -0,0 +1,450 @@
+'use client';
+
+import { ReactNode, useEffect, useRef, useState } from 'react';
+import { ArrowLeft, File, FileText, Loader2, Upload, X as XIcon } from 'lucide-react';
+import { BaseModal } from '@/components/ui/BaseModal';
+import { Button } from '@/components/ui/Button';
+import { FundingIcon } from '@/components/ui/icons/FundingIcon';
+import Icon from '@/components/ui/icons/Icon';
+import { cn } from '@/utils/styles';
+import { detectImportFormat } from '@/components/Editor/lib/convert';
+
+export type NoteCreationSource = 'template' | 'upload' | 'blank';
+
+export type NoteCreationType = 'preregistration' | 'grant' | 'research' | 'other';
+
+interface SourceOption {
+ id: NoteCreationSource;
+ title: string;
+ description: string;
+ icon: ReactNode;
+}
+
+interface TypeOption {
+ id: NoteCreationType;
+ title: string;
+ description: string;
+ icon: ReactNode;
+}
+
+const SOURCE_OPTIONS: SourceOption[] = [
+ {
+ id: 'template',
+ title: 'From a template',
+ description: 'Pick a template to start with (proposal, preprint, ...)',
+ icon: ,
+ },
+ {
+ id: 'upload',
+ title: 'Upload a document',
+ description: 'Import a Word, Markdown, or OpenDocument',
+ icon: ,
+ },
+ {
+ id: 'blank',
+ title: 'Start blank',
+ description: 'Begin with an empty page',
+ icon: ,
+ },
+];
+
+// Type options when the user is in the template flow. "Other" is intentionally
+// omitted because there's no generic scaffold; pick a type or go back.
+const TYPE_OPTIONS_FOR_TEMPLATE: TypeOption[] = [
+ {
+ id: 'preregistration',
+ title: 'Proposal',
+ description: 'Crowdfund your research',
+ icon: ,
+ },
+ {
+ id: 'grant',
+ title: 'Funding Opportunity',
+ description: 'Fund specific research you care about',
+ icon: ,
+ },
+ {
+ id: 'research',
+ title: 'Preprint',
+ description: 'Publish your research as a preprint',
+ icon: ,
+ },
+];
+
+// Type options for the upload flow include "Other" so an arbitrary import
+// still has a home when it doesn't fit a typed publishing workflow.
+const TYPE_OPTIONS_FOR_UPLOAD: TypeOption[] = [
+ ...TYPE_OPTIONS_FOR_TEMPLATE,
+ {
+ id: 'other',
+ title: 'Other',
+ description: 'General document with no specific publishing type',
+ icon: ,
+ },
+];
+
+/** Maximum upload size in bytes (25 MB). Mirrors the server-side cap in convert.ts. */
+const MAX_FILE_SIZE = 25 * 1024 * 1024;
+
+// File-picker accept hint. Both MIME and extension are listed because OSes
+// inconsistently report MIME for these formats (especially .md, which often
+// arrives as `text/plain` or with no MIME at all).
+const ACCEPT_ATTR = [
+ '.docx',
+ '.odt',
+ '.md',
+ '.markdown',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.oasis.opendocument.text',
+ 'text/markdown',
+].join(',');
+
+interface NoteCreationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ /** Notebook section the new note will belong to. */
+ grouping: 'workspace' | 'private';
+ onCreateFromTemplate: (type: 'preregistration' | 'grant' | 'research') => Promise | void;
+ onCreateBlank: () => Promise | void;
+ onCreateFromUpload: (params: { file: File; type: NoteCreationType }) => Promise | void;
+ /** External processing flag from the parent (e.g. createNote pending). */
+ isProcessing?: boolean;
+}
+
+const TITLE_FOR_GROUPING: Record = {
+ workspace: 'Create workspace note',
+ private: 'Create private note',
+};
+
+// Wizard step the modal is showing. The upload flow has its own sub-step
+// (file picker first, then type tiles) because we don't want to ask users
+// "what type?" until we know they actually have a file to import.
+type Step =
+ | { kind: 'source' }
+ | { kind: 'template-type' }
+ | { kind: 'upload-file' }
+ | { kind: 'upload-type'; file: File };
+
+export const NoteCreationModal = ({
+ isOpen,
+ onClose,
+ grouping,
+ onCreateFromTemplate,
+ onCreateBlank,
+ onCreateFromUpload,
+ isProcessing = false,
+}: NoteCreationModalProps) => {
+ const [step, setStep] = useState({ kind: 'source' });
+ const [type, setType] = useState(null);
+ const [fileError, setFileError] = useState(null);
+ const fileInputRef = useRef(null);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setStep({ kind: 'source' });
+ setType(null);
+ setFileError(null);
+ }
+ }, [isOpen]);
+
+ const handleSourceSelect = (next: NoteCreationSource) => {
+ setType(null);
+ setFileError(null);
+
+ if (next === 'blank') {
+ // No second step for blank — fire-and-forget; the parent owns the
+ // loading state and closes the modal when the create flow resolves.
+ void onCreateBlank();
+ return;
+ }
+
+ if (next === 'template') {
+ setStep({ kind: 'template-type' });
+ return;
+ }
+
+ setStep({ kind: 'upload-file' });
+ };
+
+ const handleBack = () => {
+ if (step.kind === 'upload-type') {
+ // Going back from type selection should land on the file picker, not
+ // the source list — the user explicitly chose "upload" and shouldn't
+ // be forced to re-select that.
+ setStep({ kind: 'upload-file' });
+ setType(null);
+ return;
+ }
+ setStep({ kind: 'source' });
+ setType(null);
+ setFileError(null);
+ };
+
+ const openFilePicker = () => fileInputRef.current?.click();
+
+ const handleFileChange = (event: React.ChangeEvent) => {
+ const picked = event.target.files?.[0] ?? null;
+ event.target.value = ''; // allow re-selecting the same file
+ if (!picked) return;
+
+ if (!detectImportFormat(picked)) {
+ setFileError('Only .docx, .odt, and .md files are supported.');
+ return;
+ }
+
+ if (picked.size > MAX_FILE_SIZE) {
+ setFileError('That file is larger than 25 MB. Try a smaller document.');
+ return;
+ }
+
+ setFileError(null);
+ setStep({ kind: 'upload-type', file: picked });
+ };
+
+ const handleChangeFile = () => {
+ setStep({ kind: 'upload-file' });
+ setType(null);
+ };
+
+ const handleCreate = async () => {
+ if (step.kind === 'template-type') {
+ if (!type || type === 'other') return;
+ await onCreateFromTemplate(type);
+ return;
+ }
+
+ if (step.kind === 'upload-type') {
+ if (!type) return;
+ await onCreateFromUpload({ file: step.file, type });
+ }
+ };
+
+ const canSubmit =
+ !isProcessing &&
+ ((step.kind === 'template-type' && type !== null && type !== 'other') ||
+ (step.kind === 'upload-type' && type !== null));
+
+ const showBackArrow = step.kind !== 'source';
+
+ const titleNode = (
+
+ {showBackArrow && (
+
+
+
+ )}
+
{TITLE_FOR_GROUPING[grouping]}
+
+ );
+
+ const showFooter = step.kind === 'template-type' || step.kind === 'upload-type';
+
+ const footerNode = showFooter ? (
+
+
+ {step.kind === 'template-type' && !type && <>Pick a document type to continue.>}
+ {step.kind === 'upload-type' && !type && <>Pick a document type for the import.>}
+ {step.kind === 'upload-type' && type && isProcessing && (
+ <>Converting your document — this can take a few seconds...>
+ )}
+
+
+
+ Cancel
+
+
+ {isProcessing ? (
+ <>
+
+ {step.kind === 'upload-type' ? 'Importing' : 'Creating'}
+ >
+ ) : step.kind === 'upload-type' ? (
+ 'Import & create'
+ ) : (
+ 'Create note'
+ )}
+
+
+
+ ) : undefined;
+
+ return (
+
+ {step.kind === 'source' && (
+
+
+ How do you want to start?
+
+
+ {SOURCE_OPTIONS.map((option) => (
+ handleSourceSelect(option.id)}
+ />
+ ))}
+
+
+ )}
+
+ {step.kind === 'template-type' && (
+
+
+ Which template do you want to use?
+
+
+ {TYPE_OPTIONS_FOR_TEMPLATE.map((option) => (
+ setType(option.id)}
+ />
+ ))}
+
+
+ )}
+
+ {step.kind === 'upload-file' && (
+
+
+ Choose a document
+
+
+
+
+
+ Click to upload a document
+ Word, OpenDocument, or Markdown · max 25 MB
+
+ {fileError && (
+
+ {fileError}
+
+ )}
+
+
+ )}
+
+ {step.kind === 'upload-type' && (
+
+
+
+ Selected file
+
+
+
+
+
+
+
{step.file.name}
+
{(step.file.size / 1024).toFixed(0)} KB
+
+
+
+
+
+
+
+
+
+ What type of document is this?
+
+
+ {TYPE_OPTIONS_FOR_UPLOAD.map((option) => (
+ setType(option.id)}
+ />
+ ))}
+
+
+
+ )}
+
+ );
+};
+
+interface OptionTileProps {
+ title: string;
+ description: string;
+ icon: ReactNode;
+ onClick: () => void;
+ selected?: boolean;
+ disabled?: boolean;
+ isLoading?: boolean;
+}
+
+const OptionTile = ({
+ title,
+ description,
+ icon,
+ onClick,
+ selected = false,
+ disabled = false,
+ isLoading = false,
+}: OptionTileProps) => (
+
+
+ {isLoading ? : icon}
+
+
+
{title}
+
{description}
+
+
+);
diff --git a/package-lock.json b/package-lock.json
index 3c1168ed3..781b4449d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -37,6 +37,7 @@
"@tiptap-pro/extension-drag-handle-react": "^2.16.0",
"@tiptap-pro/extension-emoji": "^2.16.0",
"@tiptap-pro/extension-file-handler": "^2.16.0",
+ "@tiptap-pro/extension-import": "^2.21.8",
"@tiptap-pro/extension-mathematics": "^2.16.0",
"@tiptap-pro/extension-node-range": "^2.16.0",
"@tiptap-pro/extension-table-of-contents": "^2.16.0",
@@ -9860,6 +9861,20 @@
"@tiptap/pm": "^2.0.0"
}
},
+ "node_modules/@tiptap-pro/extension-import": {
+ "version": "2.21.8",
+ "resolved": "https://registry.tiptap.dev/@tiptap-pro%2fextension-import/-/extension-import-2.21.8.tgz",
+ "integrity": "sha512-kkuChXOY0KplxGlk4KBPxADRNJEg/6aPJffUig11YpUoOgKC2GW14Ayo8FXcb0MrNvPAgCSAKuqK5Mo19i4biw==",
+ "license": "SEE LICENSE IN LICENSE.md",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^2.0.0",
+ "@tiptap/pm": "^2.0.0"
+ }
+ },
"node_modules/@tiptap-pro/extension-mathematics": {
"version": "2.21.5",
"resolved": "https://registry.tiptap.dev/@tiptap-pro%2fextension-mathematics/-/extension-mathematics-2.21.5.tgz",
diff --git a/package.json b/package.json
index 818ed8ba5..54b313adc 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"@tiptap-pro/extension-drag-handle-react": "^2.16.0",
"@tiptap-pro/extension-emoji": "^2.16.0",
"@tiptap-pro/extension-file-handler": "^2.16.0",
+ "@tiptap-pro/extension-import": "^2.21.8",
"@tiptap-pro/extension-mathematics": "^2.16.0",
"@tiptap-pro/extension-node-range": "^2.16.0",
"@tiptap-pro/extension-table-of-contents": "^2.16.0",
From fe36a446e0fea8d476d6d416acc50ac093ccd044 Mon Sep 17 00:00:00 2001
From: Kobe Attias
Date: Sun, 24 May 2026 21:26:30 -0400
Subject: [PATCH 2/5] Include upload option in modal
---
app/notebook/[orgSlug]/[noteId]/page.tsx | 36 ++----
app/notebook/[orgSlug]/page.tsx | 92 ++++++++++++--
components/modals/FundingTimelineModal.tsx | 133 +++++++++++++++++++--
3 files changed, 212 insertions(+), 49 deletions(-)
diff --git a/app/notebook/[orgSlug]/[noteId]/page.tsx b/app/notebook/[orgSlug]/[noteId]/page.tsx
index 19c8217a2..f29c56221 100644
--- a/app/notebook/[orgSlug]/[noteId]/page.tsx
+++ b/app/notebook/[orgSlug]/[noteId]/page.tsx
@@ -1,29 +1,15 @@
'use client';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { useEffect, useState } from 'react';
-import { FundingTimelineModal } from '@/components/modals/FundingTimelineModal';
-
+// This page intentionally renders nothing. The actual note editor is
+// rendered by `app/notebook/[orgSlug]/layout.tsx`, which mounts the
+// editor based on the URL. This file exists so that the route resolves
+// when a user navigates to /notebook//.
+//
+// The funding-timeline modal used to render here when the org page
+// auto-created a proposal and forwarded `?newFunding=true` to the new
+// note's URL. That flow now lives on the org page itself
+// (`app/notebook/[orgSlug]/page.tsx`) where the modal can drive the
+// note creation, rather than appearing after the note already exists.
export default function NotePage() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const isNewFunding = searchParams?.get('newFunding') === 'true';
- const [showFundingModal, setShowFundingModal] = useState(isNewFunding);
-
- const stripNewFundingParam = () => {
- // Strip the one-time query param so the modal doesn't re-appear on refresh or back navigation
- const url = new URL(globalThis.window.location.href);
- url.searchParams.delete('newFunding');
- router.replace(url.pathname + url.search, { scroll: false });
- };
-
- useEffect(() => {
- if (isNewFunding) {
- stripNewFundingParam();
- }
- }, [isNewFunding]); // eslint-disable-line react-hooks/exhaustive-deps
-
- return (
- setShowFundingModal(false)} />
- );
+ return null;
}
diff --git a/app/notebook/[orgSlug]/page.tsx b/app/notebook/[orgSlug]/page.tsx
index 161cf9ab5..b8e0a7860 100644
--- a/app/notebook/[orgSlug]/page.tsx
+++ b/app/notebook/[orgSlug]/page.tsx
@@ -2,8 +2,9 @@
import { useOrganizationContext } from '@/contexts/OrganizationContext';
import { useNotebookContext } from '@/contexts/NotebookContext';
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
+import toast from 'react-hot-toast';
import proposalTemplate from '@/components/Editor/lib/data/proposalTemplate';
import { getInitialContent, initialContent } from '@/components/Editor/lib/data/initialContent';
import grantTemplate from '@/components/Editor/lib/data/grantTemplate';
@@ -14,6 +15,8 @@ import {
import { useCreateNote, useNoteContent } from '@/hooks/useNote';
import { NoteCreationPopover } from '@/components/Notebook/NoteCreationPopover';
import { NotePaperSkeleton } from '@/components/Notebook/NotePaperSkeleton';
+import { FundingTimelineModal } from '@/components/modals/FundingTimelineModal';
+import { importDocumentToTiptap } from '@/components/Editor/lib/convert';
export default function OrganizationPage() {
const router = useRouter();
@@ -24,11 +27,23 @@ export default function OrganizationPage() {
const [{ isLoading: isCreatingNote }, createNote] = useCreateNote();
const [{ isLoading: isUpdatingContent }, updateNoteContent] = useNoteContent();
+ const [isImporting, setIsImporting] = useState(false);
const isNewFunding = searchParams.get('newFunding') === 'true';
const isNewResearch = searchParams.get('newResearch') === 'true';
const isNewGrant = searchParams.get('newGrant') === 'true';
+ const [showFundingModal, setShowFundingModal] = useState(false);
+ useEffect(() => {
+ if (isNewFunding) setShowFundingModal(true);
+ }, [isNewFunding]);
+
+ const stripQueryParam = (key: string) => {
+ const url = new URL(globalThis.window.location.href);
+ url.searchParams.delete(key);
+ router.replace(url.pathname + url.search, { scroll: false });
+ };
+
const createNoteWithContent = async (
orgSlug: string,
{
@@ -71,14 +86,7 @@ export default function OrganizationPage() {
useEffect(() => {
if (!selectedOrg) return;
- if (isNewFunding) {
- createNoteWithContent(selectedOrg.slug, {
- template: proposalTemplate,
- queryParam: 'newFunding',
- queryValue: 'true',
- documentType: 'PREREGISTRATION',
- });
- } else if (isNewResearch) {
+ if (isNewResearch) {
createNoteWithContent(selectedOrg.slug, {
template: getInitialContent('research'),
queryParam: 'newResearch',
@@ -93,11 +101,73 @@ export default function OrganizationPage() {
documentType: 'GRANT',
});
}
- }, [selectedOrg, isNewFunding, isNewResearch, isNewGrant]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [selectedOrg, isNewResearch, isNewGrant]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const handleStartFromTemplate = async () => {
+ if (!selectedOrg) return;
+ await createNoteWithContent(selectedOrg.slug, {
+ template: proposalTemplate,
+ queryParam: 'template',
+ queryValue: 'preregistration',
+ documentType: 'PREREGISTRATION',
+ });
+ };
+
+ const handleUploadFile = async (file: File) => {
+ if (!selectedOrg) return;
+ setIsImporting(true);
+ try {
+ const result = await importDocumentToTiptap(file);
+ const newNote = await createNote({
+ organizationSlug: selectedOrg.slug,
+ title: result.title,
+ grouping: 'WORKSPACE',
+ documentType: 'PREREGISTRATION',
+ });
+
+ if (newNote) {
+ await updateNoteContent({
+ note: newNote.id,
+ fullSrc: result.html,
+ fullJson: JSON.stringify(result.json),
+ plainText: result.plainText,
+ });
+ refreshNotes();
+ router.replace(`/notebook/${selectedOrg.slug}/${newNote.id}`);
+ }
+ } catch (error) {
+ console.error('Failed to import proposal document:', error);
+ const message =
+ error instanceof Error
+ ? error.message
+ : 'Failed to import document. Please try a different file.';
+ toast.error(message, { style: { width: '320px' } });
+ } finally {
+ setIsImporting(false);
+ }
+ };
+
+ const handleFundingModalClose = () => {
+ setShowFundingModal(false);
+ stripQueryParam('newFunding');
+ };
if (isLoadingOrg) {
return ;
}
- return ;
+ const isProposalProcessing = isCreatingNote || isUpdatingContent || isImporting;
+
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/components/modals/FundingTimelineModal.tsx b/components/modals/FundingTimelineModal.tsx
index 11c281768..de45540c8 100644
--- a/components/modals/FundingTimelineModal.tsx
+++ b/components/modals/FundingTimelineModal.tsx
@@ -1,17 +1,89 @@
import { Dialog, Transition } from '@headlessui/react';
-import { Fragment } from 'react';
-import { Pencil, FileText, Rocket } from 'lucide-react';
+import { Fragment, useRef, useState } from 'react';
+import { Pencil, FileText, Rocket, Loader2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/Button';
+import { detectImportFormat } from '@/components/Editor/lib/convert';
interface FundingTimelineModalProps {
isOpen: boolean;
onClose: () => void;
+ /**
+ * Called when the user picks "Start from template". The parent is
+ * responsible for creating the proposal note from the proposal template
+ * and navigating to it.
+ */
+ onStartFromTemplate?: () => void | Promise;
+ /**
+ * Called when the user picks "Upload a file" and selects a valid
+ * document. The parent is responsible for converting the file and
+ * creating the proposal note.
+ */
+ onUploadFile?: (file: File) => void | Promise;
+ /**
+ * Disables both CTAs and shows a spinner. Use while the parent is
+ * creating/converting the note.
+ */
+ isProcessing?: boolean;
}
-export const FundingTimelineModal: React.FC = ({ isOpen, onClose }) => {
+// File-picker accept hint. Matches the canonical list in `NoteCreationModal`
+// so the proposal upload flow accepts the exact same formats as the sidebar.
+const ACCEPT_ATTR = [
+ '.docx',
+ '.odt',
+ '.md',
+ '.markdown',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.oasis.opendocument.text',
+ 'text/markdown',
+].join(',');
+
+const MAX_FILE_SIZE = 25 * 1024 * 1024;
+
+export const FundingTimelineModal: React.FC = ({
+ isOpen,
+ onClose,
+ onStartFromTemplate,
+ onUploadFile,
+ isProcessing = false,
+}) => {
+ const fileInputRef = useRef(null);
+ const [fileError, setFileError] = useState(null);
+
+ const openFilePicker = () => {
+ setFileError(null);
+ fileInputRef.current?.click();
+ };
+
+ const handleFileChange = (event: React.ChangeEvent) => {
+ const picked = event.target.files?.[0] ?? null;
+ event.target.value = ''; // allow re-selecting the same file
+ if (!picked) return;
+
+ if (!detectImportFormat(picked)) {
+ setFileError('Only .docx, .odt, and .md files are supported.');
+ return;
+ }
+ if (picked.size > MAX_FILE_SIZE) {
+ setFileError('That file is larger than 25 MB. Try a smaller document.');
+ return;
+ }
+
+ setFileError(null);
+ void onUploadFile?.(picked);
+ };
+
+ // The modal is dismissable by ESC/backdrop click only while the parent
+ // isn't mid-creation — otherwise the user could close it while a note is
+ // being conjured up in the background, leaving them on an unhelpful page.
+ const handleDialogClose = () => {
+ if (isProcessing) return;
+ onClose();
+ };
+
return (
-
+
= ({ isOp
Add authors and topics in the sidebar. Include the funding goal from your
document.
- {/*
-
- ✨ Boost your campaign by uploading a research image for NFT rewards
-
-
*/}
@@ -114,11 +181,51 @@ export const FundingTimelineModal: React.FC = ({ isOp
-
-
- Let's do it
-
+
+ {fileError && (
+
+ {fileError}
+
+ )}
+ {isProcessing && (
+
+ Preparing your proposal — this can take a few seconds...
+
+ )}
+
+
+
+ Upload a file
+
+ void onStartFromTemplate?.()}
+ disabled={isProcessing}
+ >
+ {isProcessing ? (
+ <>
+
+ Creating
+ >
+ ) : (
+ 'Start from template'
+ )}
+
+
+
+
From f12257f74b49f67b674ed96d3149e97ea334ba61 Mon Sep 17 00:00:00 2001
From: Kobe Attias
Date: Sun, 24 May 2026 22:11:40 -0400
Subject: [PATCH 3/5] Turn into submenu
---
.../menus/ContentItemMenu/ContentItemMenu.tsx | 128 ++++++++++++------
.../hooks/useContentItemActions.tsx | 23 ++++
.../components/ui/Dropdown/Dropdown.tsx | 53 ++++----
.../Editor/extensions/SlashCommand/groups.ts | 44 ++++++
.../Editor/extensions/SlashCommand/types.ts | 11 ++
5 files changed, 195 insertions(+), 64 deletions(-)
diff --git a/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx b/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx
index 7f6aa624a..750ee635e 100644
--- a/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx
+++ b/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx
@@ -3,12 +3,15 @@ import { Toolbar } from '@/components/Editor/components/ui/Toolbar';
import DragHandle from '@tiptap-pro/extension-drag-handle-react';
import { Editor } from '@tiptap/react';
-import * as Popover from '@radix-ui/react-popover';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { Surface } from '@/components/Editor/components/ui/Surface';
import { DropdownButton } from '@/components/Editor/components/ui/Dropdown';
+import { ChevronRight } from 'lucide-react';
+import GROUPS from '@/components/Editor/extensions/SlashCommand/groups';
+import { Command } from '@/components/Editor/extensions/SlashCommand/types';
import useContentItemActions from './hooks/useContentItemActions';
import { useData } from './hooks/useData';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
export type ContentItemMenuProps = {
editor: Editor;
@@ -27,6 +30,22 @@ export const ContentItemMenu = ({ editor }: ContentItemMenuProps) => {
}
}, [editor, menuOpen]);
+ // Filter the shared slash-command GROUPS down to the items that make
+ // sense inside the "Turn into" submenu. Recomputed when the menu opens
+ // so `shouldBeHidden(editor)` reflects the current editor state (e.g.
+ // hide code block when the cursor is inside a columns layout).
+ const turnIntoGroups = useMemo(() => {
+ if (!menuOpen) return [];
+ return GROUPS.map((group) => ({
+ ...group,
+ commands: group.commands.filter((command) => {
+ if (command.hideFromTurnInto) return false;
+ if (command.shouldBeHidden?.(editor)) return false;
+ return true;
+ }),
+ })).filter((group) => group.commands.length > 0);
+ }, [editor, menuOpen]);
+
return (
{
-
-
+
+
-
-
-
-
-
-
- Clear formatting
-
-
-
-
-
- Copy to clipboard
-
-
-
-
-
- Duplicate
-
-
-
-
-
-
- Delete
-
-
-
-
-
+
+
+
+
+
+
+
+
+ Turn into
+
+
+
+
+
+
+ {turnIntoGroups.map((group) => (
+
+
+ {group.title}
+
+ {group.commands.map((command: Command) => (
+
actions.turnInto(command)}
+ >
+
+
+ {command.label}
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+
+
+ Clear formatting
+
+
+
+
+
+ Copy to clipboard
+
+
+
+
+
+ Duplicate
+
+
+
+
+
+
+ Delete
+
+
+
+
+
+
);
diff --git a/components/Editor/components/menus/ContentItemMenu/hooks/useContentItemActions.tsx b/components/Editor/components/menus/ContentItemMenu/hooks/useContentItemActions.tsx
index 5707041c7..53d726a41 100644
--- a/components/Editor/components/menus/ContentItemMenu/hooks/useContentItemActions.tsx
+++ b/components/Editor/components/menus/ContentItemMenu/hooks/useContentItemActions.tsx
@@ -2,6 +2,7 @@ import { Node } from '@tiptap/pm/model';
import { NodeSelection } from '@tiptap/pm/state';
import { Editor } from '@tiptap/react';
import { useCallback } from 'react';
+import { Command } from '@/components/Editor/extensions/SlashCommand/types';
const useContentItemActions = (
editor: Editor,
@@ -79,12 +80,34 @@ const useContentItemActions = (
}
}, [currentNode, currentNodePos, editor]);
+ const turnInto = useCallback(
+ (command: Command) => {
+ if (currentNodePos === -1) return;
+
+ if (command.convertAction) {
+ editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).run();
+ command.convertAction(editor);
+ return;
+ }
+
+ editor
+ .chain()
+ .setMeta('hideDragHandle', true)
+ .setNodeSelection(currentNodePos)
+ .deleteSelection()
+ .run();
+ command.action(editor);
+ },
+ [editor, currentNodePos]
+ );
+
return {
resetTextFormatting,
duplicateNode,
copyNodeToClipboard,
deleteNode,
handleAdd,
+ turnInto,
};
};
diff --git a/components/Editor/components/ui/Dropdown/Dropdown.tsx b/components/Editor/components/ui/Dropdown/Dropdown.tsx
index cc1513c52..1c2548248 100644
--- a/components/Editor/components/ui/Dropdown/Dropdown.tsx
+++ b/components/Editor/components/ui/Dropdown/Dropdown.tsx
@@ -9,30 +9,31 @@ export const DropdownCategoryTitle = ({ children }: { children: React.ReactNode
);
};
-export const DropdownButton = React.forwardRef<
- HTMLButtonElement,
- {
- children: React.ReactNode;
- isActive?: boolean;
- onClick?: () => void;
- disabled?: boolean;
- className?: string;
- }
->(function DropdownButtonInner({ children, isActive, onClick, disabled, className }, ref) {
- const buttonClass = cn(
- 'flex items-center gap-2 p-1.5 text-sm font-medium text-neutral-500 dark:text-neutral-400 text-left bg-transparent w-full rounded',
- !isActive && !disabled,
- 'hover:bg-neutral-100 hover:text-neutral-800 dark:hover:bg-neutral-900 dark:hover:text-neutral-200',
- isActive &&
- !disabled &&
- 'bg-neutral-100 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200',
- disabled && 'text-neutral-400 cursor-not-allowed dark:text-neutral-600',
- className
- );
+type DropdownButtonProps = React.ButtonHTMLAttributes & {
+ isActive?: boolean;
+};
- return (
-
- {children}
-
- );
-});
+export const DropdownButton = React.forwardRef(
+ function DropdownButtonInner({ children, isActive, disabled, className, ...rest }, ref) {
+ const buttonClass = cn(
+ 'flex items-center gap-2 p-1.5 text-sm font-medium text-neutral-500 dark:text-neutral-400 text-left bg-transparent w-full rounded',
+ !isActive && !disabled,
+ 'hover:bg-neutral-100 hover:text-neutral-800 dark:hover:bg-neutral-900 dark:hover:text-neutral-200',
+ isActive &&
+ !disabled &&
+ 'bg-neutral-100 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200',
+ disabled && 'text-neutral-400 cursor-not-allowed dark:text-neutral-600',
+ className
+ );
+
+ // Spread `rest` so that Radix-injected props (onPointerMove,
+ // onPointerLeave, onKeyDown, data-state, aria-*, id) reach the
+ // underlying . Without this, `asChild` patterns silently lose
+ // Radix's hover-to-open and keyboard handling.
+ return (
+
+ {children}
+
+ );
+ }
+);
diff --git a/components/Editor/extensions/SlashCommand/groups.ts b/components/Editor/extensions/SlashCommand/groups.ts
index d5744fc2d..d47426dba 100644
--- a/components/Editor/extensions/SlashCommand/groups.ts
+++ b/components/Editor/extensions/SlashCommand/groups.ts
@@ -27,6 +27,19 @@ export const GROUPS: Group[] = [
name: 'format',
title: 'Format',
commands: [
+ {
+ name: 'paragraph',
+ label: 'Paragraph',
+ iconName: 'Pilcrow',
+ description: 'Plain text paragraph',
+ aliases: ['p', 'text'],
+ action: (editor) => {
+ editor.chain().focus().setParagraph().run();
+ },
+ convertAction: (editor) => {
+ editor.chain().focus().setParagraph().run();
+ },
+ },
{
name: 'heading1',
label: 'Heading 1',
@@ -36,6 +49,9 @@ export const GROUPS: Group[] = [
action: (editor) => {
editor.chain().focus().setHeading({ level: 1 }).run();
},
+ convertAction: (editor) => {
+ editor.chain().focus().setHeading({ level: 1 }).run();
+ },
},
{
name: 'heading2',
@@ -46,6 +62,9 @@ export const GROUPS: Group[] = [
action: (editor) => {
editor.chain().focus().setHeading({ level: 2 }).run();
},
+ convertAction: (editor) => {
+ editor.chain().focus().setHeading({ level: 2 }).run();
+ },
},
{
name: 'heading3',
@@ -56,6 +75,9 @@ export const GROUPS: Group[] = [
action: (editor) => {
editor.chain().focus().setHeading({ level: 3 }).run();
},
+ convertAction: (editor) => {
+ editor.chain().focus().setHeading({ level: 3 }).run();
+ },
},
{
name: 'bulletList',
@@ -66,6 +88,9 @@ export const GROUPS: Group[] = [
action: (editor) => {
editor.chain().focus().toggleBulletList().run();
},
+ convertAction: (editor) => {
+ editor.chain().focus().toggleBulletList().run();
+ },
},
{
name: 'numberedList',
@@ -76,6 +101,9 @@ export const GROUPS: Group[] = [
action: (editor) => {
editor.chain().focus().toggleOrderedList().run();
},
+ convertAction: (editor) => {
+ editor.chain().focus().toggleOrderedList().run();
+ },
},
{
name: 'taskList',
@@ -86,6 +114,9 @@ export const GROUPS: Group[] = [
action: (editor) => {
editor.chain().focus().toggleTaskList().run();
},
+ convertAction: (editor) => {
+ editor.chain().focus().toggleTaskList().run();
+ },
},
{
name: 'toggleList',
@@ -96,6 +127,9 @@ export const GROUPS: Group[] = [
action: (editor) => {
editor.chain().focus().setDetails().run();
},
+ convertAction: (editor) => {
+ editor.chain().focus().setDetails().run();
+ },
},
{
name: 'blockquote',
@@ -105,6 +139,9 @@ export const GROUPS: Group[] = [
action: (editor) => {
editor.chain().focus().setBlockquote().run();
},
+ convertAction: (editor) => {
+ editor.chain().focus().setBlockquote().run();
+ },
},
{
name: 'codeBlock',
@@ -115,6 +152,9 @@ export const GROUPS: Group[] = [
action: (editor) => {
editor.chain().focus().setCodeBlock().run();
},
+ convertAction: (editor) => {
+ editor.chain().focus().setCodeBlock().run();
+ },
},
],
},
@@ -185,6 +225,10 @@ export const GROUPS: Group[] = [
iconName: 'Calculator',
aliases: ['math', 'equation', 'latex'],
description: 'Insert an inline math equation',
+ // Inline math is inline-level content; it can't replace a block
+ // node, so hide it from the "Turn into" menu while keeping it
+ // available from the slash menu and the `+` button.
+ hideFromTurnInto: true,
action: (editor) => {
editor
.chain()
diff --git a/components/Editor/extensions/SlashCommand/types.ts b/components/Editor/extensions/SlashCommand/types.ts
index 11a973eb4..f42234916 100644
--- a/components/Editor/extensions/SlashCommand/types.ts
+++ b/components/Editor/extensions/SlashCommand/types.ts
@@ -14,8 +14,19 @@ export interface Command {
description: string;
aliases?: string[];
iconName: keyof typeof icons;
+ /**
+ * Run by the slash-command picker and the `+` insert button — inserts a
+ * new node at the cursor or replaces an empty paragraph. Always defined.
+ */
action: (editor: Editor) => void;
+ convertAction?: (editor: Editor) => void;
shouldBeHidden?: (editor: Editor) => boolean;
+ /**
+ * Optional. Hide this command from the "Turn into" submenu specifically.
+ * Use for items where a block-level conversion makes no semantic sense
+ * (e.g. inline math is inline-level, not a block).
+ */
+ hideFromTurnInto?: boolean;
}
export interface MenuListProps {
From df7f73cfa7e0a94b0a9b0284d8c15dffbfcf628e Mon Sep 17 00:00:00 2001
From: Kobe Attias
Date: Sun, 24 May 2026 22:27:15 -0400
Subject: [PATCH 4/5] Remove hover effect around document option
---
components/Editor/styles/index.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/Editor/styles/index.css b/components/Editor/styles/index.css
index b5437bcff..d2df59433 100644
--- a/components/Editor/styles/index.css
+++ b/components/Editor/styles/index.css
@@ -9,7 +9,7 @@
@import './partials/readonly.css';
.ProseMirror {
- @apply caret-black dark:caret-white outline-0 pr-8 pl-20 py-16 z-0 lg:pl-8 lg:pr-8 mx-auto max-w-4xl;
+ @apply caret-black dark:caret-white outline-none pr-8 pl-20 py-16 z-0 lg:pl-8 lg:pr-8 mx-auto max-w-4xl;
.selection {
@apply inline;
From 86c6036a2b9decadc6b68c4dfa38b9f713eb2e4a Mon Sep 17 00:00:00 2001
From: Kobe Attias
Date: Sun, 24 May 2026 22:37:26 -0400
Subject: [PATCH 5/5] Spacing in notebook turn into menu
---
.../components/menus/ContentItemMenu/ContentItemMenu.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx b/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx
index 750ee635e..0d6bb4d71 100644
--- a/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx
+++ b/components/Editor/components/menus/ContentItemMenu/ContentItemMenu.tsx
@@ -11,7 +11,7 @@ import GROUPS from '@/components/Editor/extensions/SlashCommand/groups';
import { Command } from '@/components/Editor/extensions/SlashCommand/types';
import useContentItemActions from './hooks/useContentItemActions';
import { useData } from './hooks/useData';
-import { useEffect, useMemo, useState } from 'react';
+import { Fragment, useEffect, useMemo, useState } from 'react';
export type ContentItemMenuProps = {
editor: Editor;
@@ -81,8 +81,8 @@ export const ContentItemMenu = ({ editor }: ContentItemMenuProps) => {
{turnIntoGroups.map((group) => (
-
-
+
+
{group.title}
{group.commands.map((command: Command) => (
@@ -97,7 +97,7 @@ export const ContentItemMenu = ({ editor }: ContentItemMenuProps) => {
))}
-
+
))}