Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 11 additions & 25 deletions app/notebook/[orgSlug]/[noteId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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/<org>/<noteId>.
//
// 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 (
<FundingTimelineModal isOpen={showFundingModal} onClose={() => setShowFundingModal(false)} />
);
return null;
}
92 changes: 81 additions & 11 deletions app/notebook/[orgSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -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,
{
Expand Down Expand Up @@ -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',
Expand All @@ -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 <NotePaperSkeleton />;
}

return <NoteCreationPopover isOpen={isCreatingNote || isUpdatingContent} />;
const isProposalProcessing = isCreatingNote || isUpdatingContent || isImporting;

return (
<>
<NoteCreationPopover isOpen={isCreatingNote || isUpdatingContent} />
<FundingTimelineModal
isOpen={showFundingModal}
onClose={handleFundingModalClose}
onStartFromTemplate={handleStartFromTemplate}
onUploadFile={handleUploadFile}
isProcessing={isProposalProcessing}
/>
</>
);
}
36 changes: 36 additions & 0 deletions app/notebook/api/convert/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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 }));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 { Fragment, useEffect, useMemo, useState } from 'react';

export type ContentItemMenuProps = {
editor: Editor;
Expand All @@ -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 (
<DragHandle
pluginKey="ContentItemMenu"
Expand All @@ -41,45 +60,78 @@ export const ContentItemMenu = ({ editor }: ContentItemMenuProps) => {
<Toolbar.Button onClick={actions.handleAdd}>
<Icon name="Plus" />
</Toolbar.Button>
<Popover.Root open={menuOpen} onOpenChange={setMenuOpen}>
<Popover.Trigger asChild>
<DropdownMenu.Root open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
<DropdownMenu.Trigger asChild>
<Toolbar.Button>
<Icon name="GripVertical" />
</Toolbar.Button>
</Popover.Trigger>
<Popover.Content side="bottom" align="start" sideOffset={8}>
<Surface className="p-2 flex flex-col min-w-[16rem]">
<Popover.Close>
<DropdownButton onClick={actions.resetTextFormatting}>
<Icon name="RemoveFormatting" />
Clear formatting
</DropdownButton>
</Popover.Close>
<Popover.Close>
<DropdownButton onClick={actions.copyNodeToClipboard}>
<Icon name="Clipboard" />
Copy to clipboard
</DropdownButton>
</Popover.Close>
<Popover.Close>
<DropdownButton onClick={actions.duplicateNode}>
<Icon name="Copy" />
Duplicate
</DropdownButton>
</Popover.Close>
<Toolbar.Divider horizontal />
<Popover.Close>
<DropdownButton
onClick={actions.deleteNode}
className="text-red-500 bg-red-500 dark:text-red-500 hover:bg-red-500 dark:hover:text-red-500 dark:hover:bg-red-500 bg-opacity-10 hover:bg-opacity-20 dark:hover:bg-opacity-20"
>
<Icon name="Trash2" />
Delete
</DropdownButton>
</Popover.Close>
</Surface>
</Popover.Content>
</Popover.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content side="bottom" align="start" sideOffset={8}>
<Surface className="p-2 flex flex-col min-w-[16rem]">
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger asChild>
<DropdownButton>
<Icon name="RefreshCw" />
<span className="flex-1">Turn into</span>
<ChevronRight className="w-4 h-4 text-neutral-400" />
</DropdownButton>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent sideOffset={4} alignOffset={-8}>
<Surface className="p-2 flex flex-col min-w-[16rem] max-h-[min(80vh,24rem)] overflow-auto">
{turnIntoGroups.map((group) => (
<Fragment key={group.name}>
<div className="text-neutral-500 text-[0.65rem] mx-2 mt-4 mb-1 font-semibold tracking-wider select-none uppercase first:mt-0">
{group.title}
</div>
{group.commands.map((command: Command) => (
<DropdownMenu.Item
key={command.name}
asChild
onSelect={() => actions.turnInto(command)}
>
<DropdownButton>
<Icon name={command.iconName} />
{command.label}
</DropdownButton>
</DropdownMenu.Item>
))}
</Fragment>
))}
</Surface>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
<DropdownMenu.Item asChild onSelect={actions.resetTextFormatting}>
<DropdownButton>
<Icon name="RemoveFormatting" />
Clear formatting
</DropdownButton>
</DropdownMenu.Item>
<DropdownMenu.Item asChild onSelect={actions.copyNodeToClipboard}>
<DropdownButton>
<Icon name="Clipboard" />
Copy to clipboard
</DropdownButton>
</DropdownMenu.Item>
<DropdownMenu.Item asChild onSelect={actions.duplicateNode}>
<DropdownButton>
<Icon name="Copy" />
Duplicate
</DropdownButton>
</DropdownMenu.Item>
<Toolbar.Divider horizontal />
<DropdownMenu.Item asChild onSelect={actions.deleteNode}>
<DropdownButton className="text-red-500 bg-red-500 dark:text-red-500 hover:bg-red-500 dark:hover:text-red-500 dark:hover:bg-red-500 bg-opacity-10 hover:bg-opacity-20 dark:hover:bg-opacity-20">
<Icon name="Trash2" />
Delete
</DropdownButton>
</DropdownMenu.Item>
</Surface>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
</DragHandle>
);
Expand Down
Loading