diff --git a/.env.example b/.env.example index 7b5d6ab87..849652d9f 100755 --- a/.env.example +++ b/.env.example @@ -1,15 +1,3 @@ -# Tiptap Cloud configuration -# Info: -# You need a paid plan to use the Convert feature. - -# Your Tiptap Cloud "App ID" - account-wide; the document import (Convert) uses it. -# See https://cloud.tiptap.dev/convert-settings (or any product settings page). -NEXT_PUBLIC_TIPTAP_APP_ID= - -# Your Tiptap Convert "JWT Secret", see here https://cloud.tiptap.dev/convert-settings -# Used by the legacy @tiptap-pro/extension-import for .docx imports in the notebook. -TIPTAP_CONVERT_SECRET= - # Google OAuth 2.0 Client ID and Secret # You can generate these from the Google Cloud Console # https://console.cloud.google.com/apis/credentials diff --git a/app/expert-finder/library/[searchId]/components/ExpertResultCard.tsx b/app/expert-finder/library/[searchId]/components/ExpertResultCard.tsx index dad2429c2..1e1d83424 100644 --- a/app/expert-finder/library/[searchId]/components/ExpertResultCard.tsx +++ b/app/expert-finder/library/[searchId]/components/ExpertResultCard.tsx @@ -14,7 +14,6 @@ import { Pencil, Sparkles, } from 'lucide-react'; -import { toast } from 'react-hot-toast'; import { Alert } from '@/components/ui/Alert'; import { Button } from '@/components/ui/Button'; import { Checkbox } from '@/components/ui/form/Checkbox'; @@ -33,9 +32,9 @@ import { proposalDraftStepProgress, } from '@/types/expertFinder'; import { useProposalDraft } from '@/hooks/useExpertFinder'; -import { NoteService } from '@/services/note.service'; import { ExpertFormModal } from './ExpertFormModal'; import { ExpertSourceLinkIcon } from './ExpertSourceLinkIcon'; +import { documentTarget, workspaceHref } from '@/components/AIMode/workspaceUrl'; interface ExpertResultCardProps { expert: ExpertResult; @@ -60,7 +59,6 @@ interface ProposalDraftSectionProps { draft: ProposalDraft | null; isStarting: boolean; startError: string | null; - isOpeningNote: boolean; onStart: () => void; onOpenNote: () => void; } @@ -69,7 +67,6 @@ function ProposalDraftSection({ draft, isStarting, startError, - isOpeningNote, onStart, onOpenNote, }: Readonly) { @@ -126,13 +123,8 @@ function ProposalDraftSection({ size="sm" className="w-full gap-2" onClick={onOpenNote} - disabled={isOpeningNote} > - {isOpeningNote ? ( - - ) : ( - - )} + Open proposal ); @@ -169,7 +161,6 @@ export function ExpertResultCard({ }: ExpertResultCardProps) { const router = useRouter(); const [editOpen, setEditOpen] = useState(false); - const [isOpeningNote, setIsOpeningNote] = useState(false); // Refresh the search detail once a draft settles so selection-based flows // (e.g. proposal invitation emails) see the latest draft state. @@ -186,17 +177,9 @@ export function ExpertResultCard({ if (expert.searchExpertId != null) void startDraft(expert.searchExpertId); }, [expert.searchExpertId, startDraft]); - const handleOpenNote = useCallback(async () => { + const handleOpenNote = useCallback(() => { if (draft?.noteId == null) return; - setIsOpeningNote(true); - try { - const note = await NoteService.getNote(String(draft.noteId)); - router.push(`/notebook/${note.organization.slug}/${draft.noteId}`); - } catch (e) { - toast.error(e instanceof Error ? e.message : 'Failed to open the proposal note'); - } finally { - setIsOpeningNote(false); - } + router.push(workspaceHref(documentTarget(draft.noteId))); }, [draft?.noteId, router]); const name = empty(expert.name); const canEditContact = expert.expertId != null && Boolean(onSuccess); @@ -445,9 +428,8 @@ export function ExpertResultCard({ draft={draft} isStarting={isStarting} startError={startError} - isOpeningNote={isOpeningNote} onStart={handleStartDraft} - onOpenNote={() => void handleOpenNote()} + onOpenNote={handleOpenNote} /> ) : null} {onGenerateEmail && ( diff --git a/app/layout.tsx b/app/layout.tsx index c8f920e9b..79aa40743 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,8 @@ import localFont from 'next/font/local'; import './globals.css'; +// The editor renders math with KaTeX wherever a document shows. +import 'katex/dist/katex.min.css'; import { Metadata } from 'next'; import { getServerSession } from 'next-auth'; import { authOptions } from './api/auth/[...nextauth]/auth.config'; diff --git a/app/layouts/PublishMenu.tsx b/app/layouts/PublishMenu.tsx index 0df1585e1..c4c8762f0 100644 --- a/app/layouts/PublishMenu.tsx +++ b/app/layouts/PublishMenu.tsx @@ -1,7 +1,7 @@ 'use client'; -import { Plus } from 'lucide-react'; -import { BaseMenu } from '@/components/ui/form/BaseMenu'; +import { Plus, ScrollText } from 'lucide-react'; +import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu'; import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; import { @@ -11,9 +11,19 @@ import { type FundingDraftOption, } from '@/components/Funding/fundingDraftOptions'; import { useFundingDrafting } from '@/components/Funding/useFundingDrafting'; +import { useChangelogDrafting } from '@/components/changelog/useChangelogDrafting'; +import { useUser } from '@/contexts/UserContext'; import { useScreenSize } from '@/hooks/useScreenSize'; import { useState } from 'react'; +/** Moderators also publish product updates; the entry drafts in the workspace too. */ +const CHANGELOG_OPTION = { + id: 'changelog', + title: 'ChangeLog', + description: 'Publish a ResearchHub product update', + icon: , +}; + interface PublishMenuProps { forceMinimize?: boolean; /** Runs once an item is picked, for a host that should then get out of the way. */ @@ -23,7 +33,7 @@ interface PublishMenuProps { /** * The sidebar's Publish button: a Request for Proposal or a Proposal, either * of which leads straight to a new draft in the workspace for that side of - * the money. + * the money, and for moderators a ChangeLog entry. */ export const PublishMenu: React.FC = ({ forceMinimize = false, @@ -31,6 +41,9 @@ export const PublishMenu: React.FC = ({ }) => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const { startNew } = useFundingDrafting(); + const { startChangelog } = useChangelogDrafting(); + const { user } = useUser(); + const isModerator = !!user?.isModerator; const { smAndDown } = useScreenSize(); const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false); @@ -44,6 +57,16 @@ export const PublishMenu: React.FC = ({ onItemSelected?.(); }; + const handleChangelogClick = () => { + executeAuthenticatedAction(() => { + void startChangelog(); + }); + if (smAndDown) { + setIsMobileDrawerOpen(false); + } + onItemSelected?.(); + }; + // Regular trigger for standard mode const standardTrigger = ( ); - const menuContent = ; + const menuContent = ( +
+ + {isModerator && ( + + + + )} +
+ ); // Mobile drawer content + const mobileItems = [ + ...FUNDING_DRAFT_OPTIONS.map((item) => ({ + option: item, + onPick: () => handleMenuItemClick(item), + })), + ...(isModerator ? [{ option: CHANGELOG_OPTION, onPick: handleChangelogClick }] : []), + ]; const mobileDrawerContent = (
- {FUNDING_DRAFT_OPTIONS.map((item) => ( + {mobileItems.map(({ option, onPick }) => (
handleMenuItemClick(item)} + key={option.id} + onClick={onPick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - handleMenuItemClick(item); + onPick(); } }} className="group w-full px-3 py-3 cursor-pointer rounded-xl transition-colors duration-150 hover:bg-gray-100 active:bg-gray-100" role="button" tabIndex={0} - aria-label={`${item.title}: ${item.description}`} + aria-label={`${option.title}: ${option.description}`} > - +
))}
diff --git a/app/layouts/topbar/pageRoutes.tsx b/app/layouts/topbar/pageRoutes.tsx index 3a64d109c..d4381b197 100644 --- a/app/layouts/topbar/pageRoutes.tsx +++ b/app/layouts/topbar/pageRoutes.tsx @@ -19,7 +19,6 @@ export const ROOT_NAVIGATION_PATHS = new Set([ ...HOME_TAB_PATHS, '/peer-review', '/journal', - '/notebook', '/lists', '/settings', '/endowment', diff --git a/app/note/join/[inviteKey]/page.tsx b/app/note/join/[inviteKey]/page.tsx deleted file mode 100644 index 99c95f481..000000000 --- a/app/note/join/[inviteKey]/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useTransition } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { useSession } from 'next-auth/react'; -import { Check, Loader2, LockKeyhole } from 'lucide-react'; -import { toast } from 'react-hot-toast'; - -import { PageLayout } from '@/app/layouts/PageLayout'; -import { Button } from '@/components/ui/Button'; -import { BlockEditorClientWrapper } from '@/components/Editor/components/BlockEditor/components/BlockEditorClientWrapper'; -import { NotePaperWrapper } from '@/components/Notebook/NotePaperWrapper'; -import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; -import { useAcceptNoteInvite, useNoteInvite } from '@/hooks/useNote'; - -export default function JoinNotePage() { - const params = useParams(); - const router = useRouter(); - const { status } = useSession(); - const { executeAuthenticatedAction } = useAuthenticatedAction(); - const [{ invite, isLoading: isFetching, error }, fetchInvite] = useNoteInvite(); - const [{ isLoading: isAccepting }, acceptNoteInvite] = useAcceptNoteInvite(); - const [isPending, startTransition] = useTransition(); - - const inviteKey = params?.inviteKey as string; - const note = invite?.note; - - useEffect(() => { - if (!inviteKey) return; - - fetchInvite(inviteKey).catch(() => { - console.log('Failed to fetch invited note. The invitation may be invalid or expired.'); - }); - }, [fetchInvite, inviteKey]); - - const openEditableNote = useCallback(() => { - if (!note?.organization?.slug || !note?.id) return; - - startTransition(() => { - router.push(`/notebook/${note.organization.slug}/${note.id}`); - }); - }, [note?.id, note?.organization?.slug, router]); - - const handleAcceptInvite = useCallback(async () => { - if (!inviteKey || !note) return; - - try { - await acceptNoteInvite(inviteKey); - toast.success('Note invite accepted'); - openEditableNote(); - } catch (error) { - toast.error( - 'Unable to accept this invite. Make sure you are signed in with the invited email.' - ); - } - }, [acceptNoteInvite, inviteKey, note, openEditableNote]); - - const handleAuthThenAccept = () => { - executeAuthenticatedAction(handleAcceptInvite); - }; - - if (status === 'loading' || isFetching) { - return ( - -
- -

Loading note invitation...

-
-
- ); - } - - if (error || !note) { - return ( - -
-
- -
-

Invalid Invitation

-

- This invitation link is invalid or has expired. Please ask for a new note invitation. -

- -
-
- ); - } - - return ( -
- -
-
-
-

- Invitation to claim proposal -

-

{note.title}

-

- Accept the invite to claim and edit this proposal -

-
- - -
- - - - -
-
-
- ); -} diff --git a/app/notebook/NotebookClientLayout.tsx b/app/notebook/NotebookClientLayout.tsx deleted file mode 100644 index b2aebcda4..000000000 --- a/app/notebook/NotebookClientLayout.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { ReactNode, useState } from 'react'; -import './globals.css'; -import 'cal-sans/index.css'; -import 'katex/dist/katex.min.css'; - -import '@fontsource/inter/100.css'; -import '@fontsource/inter/200.css'; -import '@fontsource/inter/300.css'; -import '@fontsource/inter/400.css'; -import '@fontsource/inter/500.css'; -import '@fontsource/inter/600.css'; -import '@fontsource/inter/700.css'; - -import { PageLayout } from '@/app/layouts/PageLayout'; -import { NotebookProvider } from '@/contexts/NotebookContext'; -import { NoteEditorLayout } from '@/components/Notebook/NoteEditorLayout'; - -function NotebookContent({ children }: Readonly<{ children: ReactNode }>) { - // The docked assistant already reserves its own gutter inside the page - // container, so keeping the container capped would centre the document in - // what's left and leave a wide dead band on either side. Release the cap for - // as long as the panel is docked. - const [isAgentChatDocked, setIsAgentChatDocked] = useState(false); - - // The shared layout's sidebar and TopBar are opaque white and the scroll - // area is transparent, so wrapping in a gray surface turns just the content - // canvas gray — no change needed to the shared PageLayout. - return ( -
- - - {children} - -
- ); -} - -export default function NotebookClientLayout({ children }: Readonly<{ children: ReactNode }>) { - return ( - - {children} - - ); -} diff --git a/app/notebook/[orgSlug]/[noteId]/not-found.tsx b/app/notebook/[orgSlug]/[noteId]/not-found.tsx deleted file mode 100644 index fcc31ef8a..000000000 --- a/app/notebook/[orgSlug]/[noteId]/not-found.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use client'; - -export default function NoteNotFound() { - return ( -
-
-

Note Not Found

-

- The note you're looking for doesn't exist or you don't have permission to view it. -

-
-
- ); -} diff --git a/app/notebook/[orgSlug]/[noteId]/page.tsx b/app/notebook/[orgSlug]/[noteId]/page.tsx deleted file mode 100644 index f29c56221..000000000 --- a/app/notebook/[orgSlug]/[noteId]/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -'use client'; - -// 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() { - return null; -} diff --git a/app/notebook/[orgSlug]/page.tsx b/app/notebook/[orgSlug]/page.tsx deleted file mode 100644 index cdae9f313..000000000 --- a/app/notebook/[orgSlug]/page.tsx +++ /dev/null @@ -1,147 +0,0 @@ -'use client'; - -import { useOrganizationContext } from '@/contexts/OrganizationContext'; -import { useNotebookContext } from '@/contexts/NotebookContext'; -import { useEffect, useState } from 'react'; -import { useSearchParams, useRouter } from 'next/navigation'; -import proposalTemplate from '@/components/Editor/lib/data/proposalTemplate'; -import { initialContent } from '@/components/Editor/lib/data/initialContent'; -import grantTemplate from '@/components/Editor/lib/data/grantTemplate'; -import { getDocumentTitle } from '@/components/Editor/lib/utils/documentTitle'; -import { createFundingDraft } from '@/components/Funding/createFundingDraft'; -import { NoteCreationPopover } from '@/components/Notebook/NoteCreationPopover'; -import { useUser } from '@/contexts/UserContext'; -import type { ID } from '@/types/root'; - -// An empty document for the "Start blank" funding-opportunity path. The -// notebook editor's schema is 'heading block+', so the document must open with -// a heading (the title) followed by at least one block. -const BLANK_DOCUMENT = { - type: 'doc', - content: [{ type: 'heading', attrs: { textAlign: 'left', level: 1 } }, { type: 'paragraph' }], -} as typeof grantTemplate; - -export default function OrganizationPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - - const { selectedOrg, isLoading: isLoadingOrg } = useOrganizationContext(); - const { refreshNotes } = useNotebookContext(); - const { user, isLoading: isLoadingUser } = useUser(); - const isModerator = !!user?.isModerator; - - const [isCreatingNote, setIsCreatingNote] = useState(false); - - const isNewFunding = searchParams.get('newFunding') === 'true'; - const isNewChangelog = searchParams.get('newChangelog') === 'true'; - const isNewGrant = searchParams.get('newGrant') === 'true'; - const grantSource = searchParams.get('grantSource'); - const proposalSource = searchParams.get('proposalSource'); - const selectedGrantId = searchParams.get('selectedGrantId') ?? undefined; - - const createNoteWithContent = async ( - orgSlug: string, - { - template, - queryParam, - queryValue, - documentType, - selectedGrantId, - }: { - template: typeof proposalTemplate | typeof grantTemplate | typeof initialContent; - queryParam?: string; - queryValue?: string; - documentType: string; - selectedGrantId?: Exclude; - } - ) => { - setIsCreatingNote(true); - try { - const newNote = await createFundingDraft({ - orgSlug, - title: getDocumentTitle(template) || 'Untitled', - documentType, - grouping: 'WORKSPACE', - selectedGrantId, - template, - }); - - const queryString = queryParam && queryValue ? `?${queryParam}=${queryValue}` : ''; - refreshNotes(); - router.replace(`/notebook/${orgSlug}/${newNote.id}${queryString}`); - } catch (err) { - console.error('Failed to create note:', err); - } finally { - setIsCreatingNote(false); - } - }; - - useEffect(() => { - if (!selectedOrg || isLoadingUser) return; - - if (isNewChangelog) { - if (!isModerator) { - router.replace(`/notebook/${selectedOrg.slug}`); - return; - } - - createNoteWithContent(selectedOrg.slug, { - template: initialContent, - queryParam: 'newChangelog', - queryValue: 'true', - documentType: 'DISCUSSION', - }); - } else if (isNewFunding) { - // "Upload a document" is handled inline in OpenProposalModal; here we - // only create from template/blank. - if (proposalSource === 'blank') { - createNoteWithContent(selectedOrg.slug, { - template: BLANK_DOCUMENT, - documentType: 'PREREGISTRATION', - selectedGrantId, - }); - } else { - handleStartFromTemplate(selectedGrantId); - } - } else if (isNewGrant) { - // "Upload a document" is handled inline in OpenFundingOpportunityModal; - // here we only create from template/blank. - createNoteWithContent(selectedOrg.slug, { - template: grantSource === 'blank' ? BLANK_DOCUMENT : grantTemplate, - queryParam: 'newGrant', - queryValue: 'true', - documentType: 'GRANT', - }); - } - }, [ - selectedOrg, - isLoadingUser, - isModerator, - isNewChangelog, - isNewFunding, - isNewGrant, - grantSource, - proposalSource, - selectedGrantId, - ]); // eslint-disable-line react-hooks/exhaustive-deps - - const handleStartFromTemplate = async (selectedGrantId?: Exclude) => { - if (!selectedOrg) return; - await createNoteWithContent(selectedOrg.slug, { - template: proposalTemplate, - queryParam: 'template', - queryValue: 'preregistration', - documentType: 'PREREGISTRATION', - selectedGrantId, - }); - }; - - // NoteEditorLayout (rendered as a sibling in the layout) already shows the - // appropriate UI (NotebookHome here), so rendering the document skeleton here - // just produces a redundant outline flash before the org resolves. - if (isLoadingOrg) { - return null; - } - - return ; -} diff --git a/app/notebook/api/convert/route.ts b/app/notebook/api/convert/route.ts deleted file mode 100644 index 74facb1d9..000000000 --- a/app/notebook/api/convert/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -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/app/notebook/editor.css b/app/notebook/editor.css deleted file mode 100644 index de6c4ff45..000000000 --- a/app/notebook/editor.css +++ /dev/null @@ -1,97 +0,0 @@ -.ProseMirror { - .is-empty::before { - @apply text-neutral-500 dark:text-neutral-400; - } - - & > *:first-child, - [data-type='column'] > * { - @apply first:mt-0 last:mb-0; - } - - & > * + * { - margin-top: 0.75em; - } - - .node-imageUpload { - @apply rounded border-2 border-dotted border-black border-opacity-10 p-2 dark:border-neutral-500; - transition: border 160ms cubic-bezier(0.45, 0.05, 0.55, 0.95); - - &:hover { - @apply border-opacity-30; - } - - &:has(.is-active), - &.has-focus { - @apply border-opacity-40; - } - } - - [data-type='columns'] { - &.has-focus [data-type='column'], - &:hover [data-type='column'] { - @apply border-neutral-300 dark:border-neutral-700; - } - - [data-type='column'].has-focus { - @apply border-neutral-400 dark:border-neutral-600; - } - } - - [data-type='column'] { - @apply rounded border-2 border-dotted border-transparent p-1; - transition: border 160ms cubic-bezier(0.45, 0.05, 0.55, 0.95); - - &:hover { - @apply border-neutral-100 dark:border-neutral-900; - } - - &:has(.is-active), - &.has-focus { - @apply border-neutral-100 dark:border-neutral-900; - } - } - - .node-imageBlock { - & img { - @apply border-2 border-transparent rounded-lg overflow-hidden; - } - - &:hover img { - @apply border-2 border-neutral-100 dark:border-neutral-900; - } - - &:has(.is-active) img, - &.has-focus img { - @apply border-2 border-neutral-800 dark:border-neutral-200; - } - } - - .node-aiWriter, - .node-aiImage, - .node-tableOfContentsNode { - &.has-focus [data-node-view-wrapper] > * { - @apply shadow-[0_0_0_2px] shadow-black/30 dark:shadow-white/30 transition-all rounded-lg; - } - } - - .ProseMirror-gapcursor + .node-imageBlock, - .ProseMirror-gapcursor + .node-imageUpload, - .ProseMirror-gapcursor + [data-type='blockquoteFigure'] { - @apply outline-neutral-700 hover:outline-neutral-600 dark:outline-neutral-300 dark:hover:outline-neutral-400; - } - - [data-type='blockquoteFigure'] > div { - @apply border-l-4 border-l-neutral-700 py-2 px-4 bg-opacity-80 rounded-lg rounded-tl-none rounded-bl-none; - @apply dark:border-l-neutral-300; - } - - [data-type='blockquoteFigure'] > blockquote { - @apply text-lg text-black; - @apply dark:text-white; - } - - [data-type='blockquoteFigure'] figcaption { - @apply text-sm text-neutral-500 mt-4 transition-all overflow-hidden; - @apply dark:text-neutral-400; - } -} diff --git a/app/notebook/globals.css b/app/notebook/globals.css deleted file mode 100644 index f5ce7cc1d..000000000 --- a/app/notebook/globals.css +++ /dev/null @@ -1,61 +0,0 @@ -@import './editor.css'; - -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - body { - @apply bg-white text-neutral-900 dark:text-white dark:bg-black; - @apply antialiased; - - font-size: 16px; - } -} - -html, -body { - @apply bg-white text-neutral-900 dark:bg-black dark:text-white; -} - -input::placeholder, -textarea::placeholder { - @apply text-black/50 dark:text-white/40; -} - -input::-webkit-input-placeholder, -textarea::-webkit-input-placeholder { - @apply text-black/50 dark:text-white/40; -} - -input::-moz-placeholder, -textarea::-moz-placeholder { - @apply text-black/40 dark:text-white/40; -} - -.react-colorful { - width: 100% !important; -} - -[data-reference-hidden] { - opacity: 0; - pointer-events: none; -} - -::-webkit-scrollbar { - @apply w-1 h-1 bg-neutral-500/20; -} - -::-webkit-scrollbar-thumb { - @apply bg-neutral-500/50 rounded-full; -} - -input[type='range'] { - @apply h-2.5 bg-neutral-200 border-0 rounded appearance-none active:bg-neutral-300 transition-all; - @apply dark:bg-neutral-700 dark:active:bg-neutral-600; - - &::-webkit-slider-thumb { - @apply appearance-none w-3 h-5 bg-neutral-800 rounded-full active:bg-neutral-900 active:w-4 active:h-6 transition-all; - @apply dark:bg-neutral-100 dark:active:bg-white; - } -} diff --git a/app/notebook/layout.tsx b/app/notebook/layout.tsx deleted file mode 100644 index 33a179f10..000000000 --- a/app/notebook/layout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Metadata } from 'next'; -import NotebookClientLayout from './NotebookClientLayout'; - -export const metadata: Metadata = { - title: 'Notebook', -}; - -export default function NotebookLayout({ children }: Readonly<{ children: React.ReactNode }>) { - return {children}; -} diff --git a/app/notebook/opengraph-image.png b/app/notebook/opengraph-image.png deleted file mode 100644 index 4a3c4d3a3..000000000 Binary files a/app/notebook/opengraph-image.png and /dev/null differ diff --git a/app/notebook/page.tsx b/app/notebook/page.tsx deleted file mode 100644 index d5f3d5f32..000000000 --- a/app/notebook/page.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useOrganizationContext } from '@/contexts/OrganizationContext'; -import { Button } from '@/components/ui/Button'; - -export default function NotebookPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - - const { selectedOrg, isLoading: isLoadingOrg, error: orgError } = useOrganizationContext(); - - useEffect(() => { - if (!selectedOrg) return; - - const currentParams = new URLSearchParams(searchParams.toString()); - const queryString = currentParams.toString() ? `?${currentParams.toString()}` : ''; - - return router.replace(`/notebook/${selectedOrg.slug}${queryString}`); - }, [selectedOrg, router, searchParams]); - - // Loading state — defer to NoteEditorLayout (sibling in the layout) to avoid a - // redundant document-outline flash while the org loads / redirect happens. - if (isLoadingOrg) { - return null; - } - - // Error state - if (orgError) { - return ( -
-
-
⚠️
-

Something went wrong

-

{orgError.message}

- -
-
- ); - } - - // This should never render as we redirect in the useEffect - return null; -} diff --git a/app/org/join/[token]/page.tsx b/app/org/join/[token]/page.tsx deleted file mode 100644 index f1e0a68a1..000000000 --- a/app/org/join/[token]/page.tsx +++ /dev/null @@ -1,148 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { Button } from '@/components/ui/Button'; -import { Loader2 } from 'lucide-react'; -import { toast } from 'react-hot-toast'; -import { useAcceptOrgInvite, useFetchOrgByInviteToken } from '@/hooks/useOrganization'; -import { useSession } from 'next-auth/react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { PageLayout } from '@/app/layouts/PageLayout'; -import { PageHeader } from '@/components/ui/PageHeader'; -import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; -import { useTransition } from 'react'; -import { Avatar } from '@/components/ui/Avatar'; - -export default function JoinOrganizationPage() { - const params = useParams(); - const router = useRouter(); - const { data: session, status } = useSession(); - const [{ isLoading: isAccepting }, acceptOrgInvite] = useAcceptOrgInvite(); - const [{ data: organization, isLoading: isFetching, error }, fetchOrgByInviteToken] = - useFetchOrgByInviteToken(); - const { executeAuthenticatedAction } = useAuthenticatedAction(); - const [isPending, startTransition] = useTransition(); - - const token = params?.token as string; - - useEffect(() => { - const fetchOrganization = async () => { - try { - await fetchOrgByInviteToken(token); - } catch (error) { - console.log( - 'Failed to fetch organization details. The invitation may be invalid or expired.' - ); - } - }; - - if (token && status !== 'loading') { - fetchOrganization(); - } - }, [token, status, fetchOrgByInviteToken]); - - const handleJoinOrg = async () => { - if (!organization) return; - - try { - await acceptOrgInvite(token); - toast.success(`Successfully joined ${organization.name}`); - - // Use startTransition for the navigation - startTransition(() => { - router.push(`/notebook/${organization.slug}`); - }); - } catch (error) { - toast.error('Failed to join organization. The invitation may be invalid or expired.'); - } - }; - - if (status === 'loading' || isFetching) { - return ( - -
- -

Loading...

-
-
- ); - } - - if (error || !organization) { - return ( - -
-
- - - -
-

Invalid Invitation

-

- This invitation link is invalid or has expired. Please contact the organization - administrator for a new invitation. -

- - - -
-
- ); - } - - return ( - - -
-
- -
- -

Join {organization.name}

-

- You have been invited to join {organization.name}. - {status === 'authenticated' && <>Click the button below to accept the invitation.} -

- - {status === 'authenticated' ? ( - - ) : ( -
-

- You need to sign in to join this organization -

- -
- )} -
-
- ); -} diff --git a/app/robots.ts b/app/robots.ts index b9fd87fe3..61d1a8077 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -11,12 +11,9 @@ export default function robots(): MetadataRoute.Robots { '/auth/', '/verify/', '/reset/', - '/org/join/', - '/note/join/', '/notifications/', '/activity/', '/my-funding', - '/notebook/', '/moderators/', '/expert-finder/', '/settings', diff --git a/components/AIMode/start/ConciergeCard.tsx b/components/AIMode/start/ConciergeCard.tsx index 9b5c21216..224650acf 100644 --- a/components/AIMode/start/ConciergeCard.tsx +++ b/components/AIMode/start/ConciergeCard.tsx @@ -1,10 +1,12 @@ 'use client'; import { ArrowUpRight } from 'lucide-react'; -import { WHITE_GLOVE_BOOKING_URL } from '@/components/Funding/OpenFundingOpportunityModal'; import { Avatar } from '@/components/ui/Avatar'; import { cn } from '@/utils/styles'; +/** Where a funder books time with the ResearchHub team to shape an RFP together. */ +const WHITE_GLOVE_BOOKING_URL = 'https://cal.com/tyler-diorio/15min'; + /** The PhD on the team who takes funders' calls. */ const CONCIERGE_AVATAR_URL = 'https://storage.prod.researchhub.com/uploads/author_profile_images/2022/08/09/blob_im4Dh4d'; diff --git a/components/AgentChat/ActivityFeed.tsx b/components/AgentChat/ActivityFeed.tsx index cdd33c4be..6c139b2ca 100644 --- a/components/AgentChat/ActivityFeed.tsx +++ b/components/AgentChat/ActivityFeed.tsx @@ -61,7 +61,7 @@ export function humanizeLabel(label: string): string { return label.replaceAll(/([a-z0-9])_(?=[a-z0-9])/g, '$1 '); } -export function hostnameOf(url: string): string { +function hostnameOf(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { @@ -462,16 +462,3 @@ export function ActivityFeed({ ); } - -/** Unique sources across a whole turn, for the Sources tab. */ -export function collectSources(items: ChatFeedItem[]): ChatActivitySource[] { - const byUrl = new Map(); - for (const item of items) { - if (item.type === 'tool_call' && item.sources) { - for (const source of item.sources) { - if (!byUrl.has(source.url)) byUrl.set(source.url, source); - } - } - } - return Array.from(byUrl.values()); -} diff --git a/components/AgentChat/ChatEmptyState.tsx b/components/AgentChat/ChatEmptyState.tsx deleted file mode 100644 index f1e3af237..000000000 --- a/components/AgentChat/ChatEmptyState.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { Logo } from '@/components/ui/Logo'; -import { ChatPresets } from './ChatPresets'; - -interface ChatEmptyStateProps { - readonly noteIsEmpty: boolean; - readonly noteIsRfp: boolean; - readonly onSelectPreset: (message: string) => void; - readonly presetsDisabled: boolean; - /** What the help line calls the thing being written: the notebook's "note", the workspace's "document". */ - readonly noun?: string; -} - -/** - * A chat on a document before its first message: who the assistant is, what - * it can do, and a few ways to start, chosen by what the document is and - * whether anything is written in it yet. - */ -export function ChatEmptyState({ - noteIsEmpty, - noteIsRfp, - onSelectPreset, - presetsDisabled, - noun = 'note', -}: ChatEmptyStateProps) { - return ( -
-
-
- -
-
-

- Research assistant -

-

- Ask questions about this {noun}, search the web and scholarly literature, or have the - assistant edit the draft for you. -

-
-
- -
- ); -} diff --git a/components/AgentChat/ChatPresets.tsx b/components/AgentChat/ChatPresets.tsx deleted file mode 100644 index 260ffefce..000000000 --- a/components/AgentChat/ChatPresets.tsx +++ /dev/null @@ -1,141 +0,0 @@ -'use client'; - -import type { ComponentType } from 'react'; -import { HandCoins, PenLine, SquarePen, Telescope } from 'lucide-react'; -import { cn } from '@/utils/styles'; - -interface ChatPreset { - readonly id: string; - readonly label: string; - readonly icon: ComponentType<{ className?: string }>; - /** Loaded into the composer as an editable starting point, not sent. */ - readonly message: string; -} - -/** - * An RFP is the funder's own call, so it takes one writing preset in both - * states — worded to work whether the note is blank or half-written, since the - * assistant reads the note either way. - */ -const DRAFT_RFP: ChatPreset = { - id: 'draft-rfp', - label: 'Draft RFP', - icon: PenLine, - message: - 'Help me draft this RFP. Ask me for anything you still need to know about the work I want to ' + - 'fund, then write the draft into the note.', -}; - -const DRAFT_PROPOSAL: ChatPreset = { - id: 'draft-proposal', - label: 'Draft proposal', - icon: PenLine, - message: - 'Help me draft this proposal. Ask me for anything you still need to know about the work, ' + - 'then generate me three hypotheses to start.', -}; - -const EDIT_PROPOSAL: ChatPreset = { - id: 'edit-proposal', - label: 'Edit proposal', - icon: SquarePen, - message: - 'Review the proposal in this note and make it stronger — tighten the writing, fill in what ' + - 'is missing, and flag anything a reviewer would push back on.', -}; - -/** - * The two searches, each in a note-shaped and an author-shaped form. Both have - * to match on something: a written note is the better brief, and an empty one - * is no brief at all, so the search falls back to the person doing it. A pair - * shares its id, since the two never appear together. - */ -const RESEARCH_FROM_NOTE: ChatPreset = { - id: 'research', - label: 'Help me research', - icon: Telescope, - message: - 'Help me research this. Search the web and the scholarly literature for the work most ' + - 'relevant to this note, and summarise what I should know, with sources.', -}; - -const RESEARCH_FROM_EXPERTISE: ChatPreset = { - id: 'research', - label: 'Help me research', - icon: Telescope, - message: - 'Help me research. Search the web and the scholarly literature for the work most relevant ' + - 'to my expertise, and summarise what I should know, with sources.', -}; - -const FUNDING_FROM_NOTE: ChatPreset = { - id: 'funding', - label: 'Find me funding', - icon: HandCoins, - message: - 'Find open RFPs I could apply to that fit this work, and tell me why each one is a match.', -}; - -const FUNDING_FROM_EXPERTISE: ChatPreset = { - id: 'funding', - label: 'Find me funding', - icon: HandCoins, - message: - 'Find open RFPs I could apply to based on my expertise, and tell me why each one is a match.', -}; - -/** - * An RFP note is written by the side handing out the money, so it gets neither - * the funding search nor the proposal-shaped review — only the call itself and - * the field it is calling into. A proposal note takes all three, its writing - * preset turning on whether there is a draft to improve yet. - */ -function presetsFor(noteIsEmpty: boolean, isRfp: boolean): ChatPreset[] { - const research = noteIsEmpty ? RESEARCH_FROM_EXPERTISE : RESEARCH_FROM_NOTE; - if (isRfp) return [DRAFT_RFP, research]; - return [ - noteIsEmpty ? DRAFT_PROPOSAL : EDIT_PROPOSAL, - research, - noteIsEmpty ? FUNDING_FROM_EXPERTISE : FUNDING_FROM_NOTE, - ]; -} - -interface ChatPresetsProps { - readonly noteIsEmpty: boolean; - readonly isRfp: boolean; - readonly onSelect: (message: string) => void; - readonly disabled: boolean; -} - -/** - * Opening moves offered on the empty chat screen. Picking one writes its - * message into the composer for the user to edit and send, so the specifics a - * generic instruction can't carry go in before the assistant sees it. - */ -export function ChatPresets({ noteIsEmpty, isRfp, onSelect, disabled }: ChatPresetsProps) { - return ( - // Stacked, not the grid this pattern usually wears: the panel is 360px at - // its narrowest, where side-by-side cards would wrap every label. -
- {presetsFor(noteIsEmpty, isRfp).map((preset) => ( - - ))} -
- ); -} diff --git a/components/AgentChat/ChatSources.tsx b/components/AgentChat/ChatSources.tsx deleted file mode 100644 index b9ede9e1d..000000000 --- a/components/AgentChat/ChatSources.tsx +++ /dev/null @@ -1,69 +0,0 @@ -'use client'; - -import { ExternalLink, Globe } from 'lucide-react'; -import type { ChatActivitySource, AgentChat } from '@/types/agentChat'; -import { collectSources, hostnameOf } from './ActivityFeed'; - -/** - * Every citation the assistant produced in one chat, deduplicated by URL and - * ordered oldest turn first. Derived from the activity feed rather than fetched - * — the transcript already carries the sources, they're just buried per tool - * call, which makes them hard to use once a chat runs long. - */ -export function collectChatSources(chat: AgentChat | null): ChatActivitySource[] { - const byUrl = new Map(); - for (const execution of chat?.executions ?? []) { - for (const source of collectSources(execution.activity ?? [])) { - if (!byUrl.has(source.url)) byUrl.set(source.url, source); - } - } - return Array.from(byUrl.values()); -} - -interface ChatSourcesProps { - readonly sources: ChatActivitySource[]; -} - -export function ChatSources({ sources }: ChatSourcesProps) { - if (sources.length === 0) { - return ( -
-
- ); - } - - return ( - - ); -} diff --git a/components/AgentChat/CreditMeter.tsx b/components/AgentChat/CreditMeter.tsx deleted file mode 100644 index 3cf65fd1c..000000000 --- a/components/AgentChat/CreditMeter.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client'; - -import type { ResearchAIState } from '@/store/researchAI'; -import { formatBudgetReset, formatCredits, isBudgetExhausted } from '@/types/researchAI'; - -export function CreditMeter({ - budget, - budgetStatus, - limitResetAt, - onRefresh, -}: Pick & { onRefresh: () => void }) { - if (budget?.tier === 'blocked') { - return ( - - Research AI is unavailable for this account. - - ); - } - if (!budget) { - return ( - - {budgetStatus === 'loading' ? 'Loading AI credits…' : 'Couldn’t load AI credits.'} - {budgetStatus === 'unavailable' && ( - - )} - - ); - } - const exhausted = isBudgetExhausted(budget) || limitResetAt !== null; - const { remaining, daily_limit: limit } = budget.credits; - const reset = formatBudgetReset(budget.resets_at); - let balanceLabel = 'Credits unavailable'; - if (limit === null) { - balanceLabel = 'Unlimited credits'; - } else if (remaining !== null) { - balanceLabel = `${formatCredits(remaining)} credits remaining`; - } - return ( -
-
- - {balanceLabel} - - -
- {exhausted && ( - - Daily AI usage limit reached. Available again at {reset}. - - )} - {budgetStatus === 'unavailable' && ( -

- Credits may be out of date.{' '} - -

- )} -
- ); -} diff --git a/components/Editor/components/BlockEditor/BlockEditor.tsx b/components/Editor/components/BlockEditor/BlockEditor.tsx index 5d27e335a..07027415d 100644 --- a/components/Editor/components/BlockEditor/BlockEditor.tsx +++ b/components/Editor/components/BlockEditor/BlockEditor.tsx @@ -10,7 +10,6 @@ import ColumnsMenu from '../../extensions/MultiColumn/menus/ColumnsMenu'; import TableRowMenu from '../../extensions/Table/menus/TableRow'; import ImageBlockMenu from '../../extensions/ImageBlock/components/ImageBlockMenu'; import TableColumnMenu from '../../extensions/Table/menus/TableColumn'; -import { useNotebookContext } from '@/contexts/NotebookContext'; export interface BlockEditorProps { content?: string; diff --git a/components/Editor/lib/convert.ts b/components/Editor/lib/convert.ts deleted file mode 100644 index 0d94a0d03..000000000 --- a/components/Editor/lib/convert.ts +++ /dev/null @@ -1,243 +0,0 @@ -'use client'; - -import { Editor, JSONContent } from '@tiptap/core'; -import type { AnyExtension } from '@tiptap/core'; -import { UndoRedo } from '@tiptap/extensions'; -import { Import } from '@tiptap-pro/extension-import'; - -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. - 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(); - - const { ExtensionKit } = await import('@/components/Editor/extensions/extension-kit'); - - // 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({}), - UndoRedo.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/Editor/lib/data/grantTemplate.tsx b/components/Editor/lib/data/grantTemplate.tsx deleted file mode 100644 index a18427946..000000000 --- a/components/Editor/lib/data/grantTemplate.tsx +++ /dev/null @@ -1,301 +0,0 @@ -interface DocumentContent { - type: string; - attrs?: { - textAlign?: string; - level?: number; - class?: string | null; - }; - content?: Array<{ - type: string; - text?: string; - marks?: Array<{ type: string; [key: string]: any }>; - content?: DocumentContent[]; - }>; -} - -interface Template { - type: 'doc'; - content: DocumentContent[]; -} - -const grantTemplate: Template = { - type: 'doc', - content: [ - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 1, - }, - content: [ - { - type: 'text', - text: '[Topic/Theme]', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: 'About this funding', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: "Briefly introduce your organization and explain why you are issuing this RFP now, including the overarching problem or topic you\'re targeting.", - marks: [{ type: 'italic' }], - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: '[Insert background and rationale here]', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: 'Topics of interest', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'State your overall goal briefly, then list a few high‐level "areas of interest" that applicants may address. These should be broad, non‐exhaustive topics.', - marks: [{ type: 'italic' }], - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Overall goal:', - marks: [{ type: 'bold' }], - }, - { - type: 'text', - text: ' [Insert one‐sentence goal]', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Specific areas of interest (not exhaustive):', - marks: [{ type: 'bold' }], - }, - ], - }, - { - type: 'bulletList', - content: [ - { - type: 'listItem', - content: [ - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: '[Area of interest 1]', - }, - ], - }, - ], - }, - { - type: 'listItem', - content: [ - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: '[Area of interest 2]', - }, - ], - }, - ], - }, - { - type: 'listItem', - content: [ - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: '…', - }, - ], - }, - ], - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: 'Funding details', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'State the total budget available for this RFP and the award range per project. Indicate whether multiple projects will be funded.', - marks: [{ type: 'italic' }], - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Total budget:', - marks: [{ type: 'bold' }], - }, - { - type: 'text', - text: ' [Insert total amount]', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Individual award range:', - marks: [{ type: 'bold' }], - }, - { - type: 'text', - text: ' [Insert minimum] – [Insert maximum]', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: '[Optional: Indicate number of projects expected to be funded]', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: 'Additional details', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Optionally include any additional details to guide submissions or generate discussion.', - marks: [{ type: 'italic' }], - }, - ], - }, - ], -}; - -export default grantTemplate; diff --git a/components/Editor/lib/data/proposalTemplate.tsx b/components/Editor/lib/data/proposalTemplate.tsx deleted file mode 100644 index 833c434ce..000000000 --- a/components/Editor/lib/data/proposalTemplate.tsx +++ /dev/null @@ -1,311 +0,0 @@ -interface DocumentContent { - type: string; - attrs?: { - textAlign?: string; - level?: number; - class?: string | null; - }; - content?: Array<{ - type: string; - text?: string; - }>; -} - -interface Template { - type: 'doc'; - content: DocumentContent[]; -} - -const proposalTemplate: Template = { - type: 'doc', - content: [ - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 1, - }, - content: [ - { - type: 'text', - text: 'Proposal Template', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: '1. Abstract', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Provide a concise abstract (max 200 words) describing (1) scientific rationale, importance and research plan, (2) description of team/facilities/ethics approvals, (3) estimated timeline, and (4) data sharing commitment.', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: '2. Introduction', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Summarize theoretical background, key literature, and rationale for the proposal.', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 3, - }, - content: [ - { - type: 'text', - text: 'Hypotheses', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'State numbered hypotheses (H1, H2, etc.) as labeled subheadings. Designate primary vs. secondary outcomes.', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: '3. Methods', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 3, - }, - content: [ - { - type: 'text', - text: 'Participants/Sample', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Define target sample, inclusion/exclusion criteria, recruitment strategy, and sample size justification (including power analysis).', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 3, - }, - content: [ - { - type: 'text', - text: 'Materials and Procedures', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Describe experimental/observational procedures, stimuli, tasks, equipment, and data collection settings with sufficient detail for replication.', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 3, - }, - content: [ - { - type: 'text', - text: 'Planned Analyses', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Outline analysis pipeline, preprocessing steps, statistical tests, and hypothesis-specific predictions. Describe how results will be interpreted regardless of outcome. Describe how deviations from the planned sample will be handled.', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 3, - }, - content: [ - { - type: 'text', - text: 'Ethics and Data Management', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'State ethical approval status or plan, confidentiality measures, and data sharing/archiving plan.', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: '4. Pilot Data (Optional)', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Summarize any pilot data or simulations that demonstrate method feasibility and inform power analysis. Clearly distinguish from main study.', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: '5. Budget', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'Provide an itemized list of anticipated costs (materials, personnel, etc.). These are not binding and are able to be adjusted based on the results of the pilot data.', - }, - ], - }, - { - type: 'heading', - attrs: { - textAlign: 'left', - level: 2, - }, - content: [ - { - type: 'text', - text: '6. References', - }, - ], - }, - { - type: 'paragraph', - attrs: { - class: null, - textAlign: 'left', - }, - content: [ - { - type: 'text', - text: 'List all references used in the manuscript using Nature style (e.g. numbered in-text citations and alphabetical references at the end of the manuscript).', - }, - ], - }, - ], -}; - -export default proposalTemplate; diff --git a/components/Funding/DocumentUploadStep.tsx b/components/Funding/DocumentUploadStep.tsx deleted file mode 100644 index 46c2aed11..000000000 --- a/components/Funding/DocumentUploadStep.tsx +++ /dev/null @@ -1,181 +0,0 @@ -'use client'; - -import { useRef, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import toast from 'react-hot-toast'; -import { ArrowLeft, Loader2, Upload } from 'lucide-react'; -import { useOrganizationContext } from '@/contexts/OrganizationContext'; -import { useCreateNote, useNoteContent } from '@/hooks/useNote'; -import { detectImportFormat, importDocumentToTiptap } from '@/components/Editor/lib/convert'; -import { cn } from '@/utils/styles'; - -// Accepted import formats for the document upload flow. -const UPLOAD_ACCEPT_ATTR = [ - '.docx', - '.odt', - '.md', - '.markdown', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.oasis.opendocument.text', - 'text/markdown', -].join(','); - -const MAX_UPLOAD_SIZE = 25 * 1024 * 1024; - -interface DocumentUploadStepProps { - /** Heading shown above the dropzone. */ - title: string; - /** Supporting copy shown under the heading. */ - description: string; - /** Note document type to create, e.g. 'GRANT' or 'PREREGISTRATION'. */ - documentType: string; - /** Invoked when the user wants to return to the previous step. */ - onBack: () => void; - /** Invoked once a note is created (used to close the host modal). */ - onClose: () => void; -} - -/** - * In-modal "upload a document" step shared by the funding-opportunity and - * proposal modals. Imports a Word/OpenDocument/Markdown file, creates a note - * from it, and navigates to the new note in the notebook. - */ -export const DocumentUploadStep = ({ - title, - description, - documentType, - onBack, - onClose, -}: DocumentUploadStepProps) => { - const router = useRouter(); - const { selectedOrg } = useOrganizationContext(); - const [, createNote] = useCreateNote(); - const [, updateNoteContent] = useNoteContent(); - const [isImporting, setIsImporting] = useState(false); - const [uploadError, setUploadError] = useState(null); - const [isDragging, setIsDragging] = useState(false); - const fileInputRef = useRef(null); - - const importDocument = async (file: File) => { - if (!selectedOrg || isImporting) return; - setIsImporting(true); - try { - const result = await importDocumentToTiptap(file); - const newNote = await createNote({ - organizationSlug: selectedOrg.slug, - title: result.title, - grouping: 'WORKSPACE', - documentType, - }); - await updateNoteContent({ - note: newNote.id, - fullSrc: result.html, - fullJson: JSON.stringify(result.json), - plainText: result.plainText, - }); - onClose(); - router.push(`/notebook/${selectedOrg.slug}/${newNote.id}`); - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Failed to import document. Please try a different file.'; - toast.error(message, { style: { width: '320px' } }); - setIsImporting(false); - } - }; - - const validateAndImport = (file: File | null) => { - if (!file) return; - if (!detectImportFormat(file)) { - setUploadError('Only .docx, .odt, and .md files are supported.'); - return; - } - if (file.size > MAX_UPLOAD_SIZE) { - setUploadError('That file is larger than 25 MB. Try a smaller document.'); - return; - } - setUploadError(null); - void importDocument(file); - }; - - const handleFileChange = (event: React.ChangeEvent) => { - const picked = event.target.files?.[0] ?? null; - event.target.value = ''; // allow re-selecting the same file - validateAndImport(picked); - }; - - const handleDrop = (event: React.DragEvent) => { - event.preventDefault(); - setIsDragging(false); - if (isImporting) return; - validateAndImport(event.dataTransfer.files?.[0] ?? null); - }; - - return ( - <> - - -
-

{title}

-

{description}

-
- - - - {uploadError && ( -

- {uploadError} -

- )} - - - - ); -}; diff --git a/components/Funding/OpenFundingOpportunityModal.tsx b/components/Funding/OpenFundingOpportunityModal.tsx deleted file mode 100644 index f6bf9bf1e..000000000 --- a/components/Funding/OpenFundingOpportunityModal.tsx +++ /dev/null @@ -1,341 +0,0 @@ -'use client'; - -import { useEffect, useState, useTransition } from 'react'; -import Link from 'next/link'; -import { Dialog } from '@headlessui/react'; -import { - ArrowLeft, - ArrowRight, - ArrowUpRight, - Award, - FileText, - Sparkles, - Upload, - Users, - X, -} from 'lucide-react'; -import { BaseModal } from '@/components/ui/BaseModal'; -import { Button, buttonVariants } from '@/components/ui/Button'; -import Icon from '@/components/ui/icons/Icon'; -import { ResearchCoinIcon } from '@/components/ui/icons/ResearchCoinIcon'; -import dynamic from 'next/dynamic'; -import AnimatedGlobe from '@/components/Globe/AnimatedGlobe'; -import { cn } from '@/utils/styles'; - -const DocumentUploadStep = dynamic( - () => import('@/components/Funding/DocumentUploadStep').then((mod) => mod.DocumentUploadStep), - { ssr: false } -); - -export type FundingOpportunityCreationMethod = 'template' | 'upload' | 'blank'; - -interface OpenFundingOpportunityModalProps { - isOpen: boolean; - onClose: () => void; - onConfirm: (method: FundingOpportunityCreationMethod) => void; - /** - * Render a compact, single-column modal that jumps straight to the creation - * method picker — no benefits step and no decorative left rail. Used by entry - * points (e.g. the notebook) where the user has already committed to opening - * a funding opportunity. - */ - minimal?: boolean; -} - -interface Benefit { - id: string; - title: string; - description: string; - icon: React.ReactNode; - learnMoreHref?: string; -} - -const BENEFITS: Benefit[] = [ - { - id: 'scientists', - title: 'The best scientists within reach', - description: - 'We source the best scientists in the world to apply for your request for proposal.', - icon: , - learnMoreHref: '/give', - }, - { - id: 'turnaround', - title: 'Fastest turnaround', - description: 'Quality proposals and peer-reviews delivered in days, not months.', - icon: , - }, - { - id: 'community', - title: 'Community matching', - description: 'With our community, every dollar you put in is stretched further.', - icon: , - }, - { - id: 'credits', - title: 'Use your Funding Credits', - description: 'Earned by holding ResearchCoin or peer reviewing.', - icon: , - learnMoreHref: '/endowments', - }, -]; - -interface CreationOption { - id: FundingOpportunityCreationMethod; - title: string; - description: string; - icon: React.ReactNode; -} - -const CREATION_OPTIONS: CreationOption[] = [ - { - id: 'template', - title: 'From a template', - description: 'Start with our RFP template', - icon: , - }, - { - id: 'upload', - title: 'Upload a document', - description: 'Import a Word, Markdown, or OpenDocument file', - icon: , - }, -]; - -/** Where a funder books time with the ResearchHub team to shape an RFP together. */ -export const WHITE_GLOVE_BOOKING_URL = 'https://cal.com/tyler-diorio/15min'; - -type Step = 'benefits' | 'method' | 'upload'; - -export const OpenFundingOpportunityModal = ({ - isOpen, - onClose, - onConfirm, - minimal = false, -}: OpenFundingOpportunityModalProps) => { - const initialStep: Step = minimal ? 'method' : 'benefits'; - const [step, setStep] = useState(initialStep); - const [pendingMethod, setPendingMethod] = useState(null); - const [isPending, startTransition] = useTransition(); - - const handleClose = () => { - if (isPending) return; - onClose(); - }; - - const handleConfirmMethod = (method: FundingOpportunityCreationMethod) => { - setPendingMethod(method); - startTransition(() => { - onConfirm(method); - }); - }; - - // Reset to the first step whenever the modal is reopened so a returning user - // always starts from the configured entry step rather than a stale step. - // Skip while a route transition is pending so the method step doesn't flash - // back to the initial step before navigation completes. - useEffect(() => { - if (!isOpen && !isPending) { - setStep(initialStep); - setPendingMethod(null); - } - }, [isOpen, initialStep, isPending]); - - return ( - -
- {/* Left gradient rail */} - {!minimal && ( -
- {/* Mobile close button (lives in the title section on small screens) */} - - {/* Soft glow blobs */} -
-
-
-
- -
- - Open a request for proposal - -

- The most efficient way to fund science. -

-
-
- )} - - {/* Right content */} -
- - - {step === 'benefits' ? ( - <> -
-

- Why fund on ResearchHub -

-
- -
- {BENEFITS.map((benefit) => ( -
-
- {benefit.icon} -
-
-
- {benefit.title} -
-
- {benefit.description} -
-
-
- ))} -
- -
-
- - Learn more - - -
-
- - ) : step === 'method' ? ( - <> - {!minimal && ( - - )} - -
-

How do you want to start?

-
- -
- {CREATION_OPTIONS.map((option) => ( - - ))} -
- -
- - or - -
- - -
- -
-
-
- White-glove setup -
-
- Book a 15-minute call and our team will set it up with you. -
-
- -
- - ) : ( - setStep('method')} - onClose={handleClose} - /> - )} -
-
- - ); -}; diff --git a/components/Funding/OpenProposalModal.tsx b/components/Funding/OpenProposalModal.tsx deleted file mode 100644 index dc29084c3..000000000 --- a/components/Funding/OpenProposalModal.tsx +++ /dev/null @@ -1,270 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Dialog } from '@headlessui/react'; -import { ArrowLeft, ArrowRight, File, FileText, Globe, Lock, Upload, Users, X } from 'lucide-react'; -import { BaseModal } from '@/components/ui/BaseModal'; -import { Button } from '@/components/ui/Button'; -import dynamic from 'next/dynamic'; -import AnimatedProposal from '@/components/Proposal/AnimatedProposal'; -import { cn } from '@/utils/styles'; - -const DocumentUploadStep = dynamic( - () => import('@/components/Funding/DocumentUploadStep').then((mod) => mod.DocumentUploadStep), - { ssr: false } -); - -export type ProposalCreationMethod = 'template' | 'upload' | 'blank'; - -interface OpenProposalModalProps { - isOpen: boolean; - onClose: () => void; - onConfirm: (method: ProposalCreationMethod) => void; - /** - * Render a compact, single-column modal that jumps straight to the creation - * method picker — no benefits step and no decorative left rail. Used by entry - * points (e.g. the notebook) where the user has already committed to creating - * a proposal. - */ - minimal?: boolean; -} - -interface Benefit { - id: string; - title: string; - description: string; - icon: React.ReactNode; -} - -const BENEFITS: Benefit[] = [ - { - id: 'community', - title: 'Reach a global community', - description: 'Put your proposal in front of thousands of funders who care about your field.', - icon: , - }, - { - id: 'matching', - title: 'Community matching', - description: 'Your proposal is eligible for funding from institutions or community members.', - icon: , - }, - { - id: 'open', - title: 'Stay open & in control', - description: 'You can control whether your proposal is public or visible to funders only.', - icon: , - }, -]; - -interface CreationOption { - id: ProposalCreationMethod; - title: string; - description: string; - icon: React.ReactNode; -} - -const CREATION_OPTIONS: CreationOption[] = [ - { - id: 'template', - title: 'From a template', - description: 'Start with our guided proposal template', - icon: , - }, - { - id: 'upload', - title: 'Upload a document', - description: 'Import a Word, Markdown, or OpenDocument file', - icon: , - }, - { - id: 'blank', - title: 'Start blank', - description: 'Begin with an empty page', - icon: , - }, -]; - -type Step = 'benefits' | 'method' | 'upload'; - -export const OpenProposalModal = ({ - isOpen, - onClose, - onConfirm, - minimal = false, -}: OpenProposalModalProps) => { - const initialStep: Step = minimal ? 'method' : 'benefits'; - const [step, setStep] = useState(initialStep); - - // Reset to the first step whenever the modal is reopened so a returning user - // always starts from the configured entry step rather than a stale step. - useEffect(() => { - if (!isOpen) setStep(initialStep); - }, [isOpen, initialStep]); - - return ( - -
- {/* Left visual rail */} - {!minimal && ( -
- {/* Mobile close button */} - - {/* Soft glow blobs */} -
-
- -
- {/* AnimatedProposal scales via CSS transform, which keeps its full - natural layout height (~367px) reserved. We pin the container to - the *visual* height so the left rail doesn't tower over the - right column and leave whitespace below the CTA. */} -
- -
- - Open a Proposal - -

- Turn your proposal into funded science. -

-
-
- )} - - {/* Right content */} -
- - - {step === 'benefits' ? ( - <> -
-

- Why publish on ResearchHub -

-
- -
- {BENEFITS.map((benefit) => ( -
-
- {benefit.icon} -
-
-
- {benefit.title} -
-
- {benefit.description} -
-
-
- ))} -
- -
-
- -
-
- - ) : step === 'method' ? ( - <> - {!minimal && ( - - )} - -
-

- How do you want to start your proposal? -

-
- -
- {CREATION_OPTIONS.map((option) => ( - - ))} -
- - ) : ( - setStep('method')} - onClose={onClose} - /> - )} -
-
- - ); -}; diff --git a/components/Funding/fundingDraftOptions.tsx b/components/Funding/fundingDraftOptions.tsx index 47ddb2114..6cc1010f6 100644 --- a/components/Funding/fundingDraftOptions.tsx +++ b/components/Funding/fundingDraftOptions.tsx @@ -42,7 +42,11 @@ export const FUNDING_DRAFT_OPTIONS: readonly FundingDraftOption[] = [ ]; /** One option's face: its icon in a tile, its name and what it is for. */ -export function FundingDraftOptionContent({ option }: { readonly option: FundingDraftOption }) { +export function FundingDraftOptionContent({ + option, +}: { + readonly option: Pick; +}) { return (
diff --git a/components/LegacyNoteBanner.tsx b/components/LegacyNoteBanner.tsx deleted file mode 100644 index 67bf1f46f..000000000 --- a/components/LegacyNoteBanner.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { AlertTriangle, ExternalLink } from 'lucide-react'; -import { isProduction } from '@/utils/featureFlags'; - -interface LegacyNoteBannerProps { - orgSlug: string; - noteId: string; -} - -/** - * Banner component that displays a warning for legacy notes - * and provides a link to view/edit them in the original notebook system - */ -export const LegacyNoteBanner = ({ orgSlug, noteId }: LegacyNoteBannerProps) => { - // Determine the correct URL based on environment - const baseUrl = isProduction() - ? 'https://old.researchhub.com' - : 'https://old.staging.researchhub.com'; - - const legacyNoteUrl = `${baseUrl}/${orgSlug}/notebook/${noteId}`; - - return ( -
-
-
- -
-
-

- - This note was created in our previous notebook system and needs to be edited there. - - - View and edit in original notebook - - -

-
-
-
- ); -}; diff --git a/components/Notebook/AgentChatPanel.tsx b/components/Notebook/AgentChatPanel.tsx deleted file mode 100644 index cd1dd9911..000000000 --- a/components/Notebook/AgentChatPanel.tsx +++ /dev/null @@ -1,644 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'; -import { Check, MessageSquarePlus, Pencil, X } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import { Loader } from '@/components/ui/Loader'; -import { cn } from '@/utils/styles'; -import { useNotebookContext } from '@/contexts/NotebookContext'; -import { useAgentChatList } from '@/hooks/useAgentChat'; -import { useChatSession } from '@/hooks/useChatSession'; -import { getChatTransport } from '@/services/chatTransport'; -import { useJumpToLatest } from '@/hooks/useJumpToLatest'; -import { MAX_AGENT_CHAT_WIDTH, MIN_AGENT_CHAT_WIDTH } from '@/hooks/useAgentChatWidth'; -import { isRfpNote } from '@/types/note'; -import { MAX_CHAT_TITLE_LENGTH, type AgentChat } from '@/types/agentChat'; -import { useEditorIsEmpty } from '@/hooks/useEditorIsEmpty'; -import { NoteReviewControls } from '@/components/Notebook/NoteReview/NoteReviewControls'; -import { NoteReviewBanner } from '@/components/Notebook/NoteReview/NoteReviewBanner'; -import { useNoteAgentReview } from '@/components/Notebook/NoteReview/useNoteAgentReview'; -import { ChatComposer } from '@/components/AgentChat/ChatComposer'; -import type { ChatNoticePolicy } from '@/components/AgentChat/chatNotices'; -import { ChatPicker } from '@/components/AgentChat/ChatPicker'; -import { ChatEmptyState } from '@/components/AgentChat/ChatEmptyState'; -import { ChatSources, collectChatSources } from '@/components/AgentChat/ChatSources'; -import { ChatTranscript } from '@/components/AgentChat/ChatTranscript'; -import { CreditMeter } from '@/components/AgentChat/CreditMeter'; -import { canSelectAIModel } from '@/types/researchAI'; -import { ModelControls } from '@/components/AgentChat/ModelControls'; - -type PanelTab = 'chat' | 'sources'; - -/** Pixels per arrow key press while the resize divider has focus. */ -const RESIZE_KEY_STEP = 24; - -/** The meter beside the composer already says when the budget is spent. */ -const NOTICES: ChatNoticePolicy = { noun: 'chat', usageLimit: 'meter' }; - -/** - * A running in-note review session, handed to the host so the accept/reject - * controls can live on the note page rather than in the chat panel. - */ -export interface NoteReviewHandle { - readonly changeCount: number; - /** Keep the assistant's side: deletes the struck ranges, keeps the rest. */ - readonly accept: () => void; - /** Keep the reader's side: deletes the inserted ranges and persists it. */ - readonly reject: () => void; -} - -interface AgentChatPanelProps { - readonly noteId: string; - readonly open: boolean; - readonly onClose: () => void; - /** The server denied access (gate changed / signed out) — hide the entry point. */ - readonly onUnavailable: () => void; - /** - * The editor's current document must be persisted as a new server version: - * the user chose "Keep mine" over an assistant version, or a reload applied - * an assistant version that newer saves had buried. Applying content - * programmatically emits no editor update, so without this save the choice - * would only live in this editor instance and vanish on the next load. - * Persists immediately and resolves with whether the save reached the - * server — the panel acknowledges the choice only on success. - */ - readonly onPersistEditorState?: () => Promise; - /** - * Docked (desktop) mode: the panel takes `width` and the host reserves the - * same gutter, so it sits beside the document instead of over it. Undocked, - * it covers the viewport as a sheet. - */ - readonly docked: boolean; - readonly width: number; - readonly isResizing: boolean; - readonly onResizeStart: () => void; - /** Keyboard resize from the divider; negative widens the panel. */ - readonly onResizeNudge: (deltaX: number) => void; - /** - * An in-note review started or ended. The host renders the accept/restore - * controls over the note; null means no review is active. - */ - readonly onReviewChange?: (review: NoteReviewHandle | null) => void; -} - -/** - * The notebook AI assistant panel: chat picker, transcript with live turn - * progress, and composer. Stays mounted while the notebook is open so chat - * selection and drafts survive closing the panel. Chat requests are gated on - * `open`; user-wide allowances load with the notebook. - */ -export function AgentChatPanel({ - noteId, - open, - onClose, - onUnavailable, - onPersistEditorState, - docked, - width, - isResizing, - onResizeStart, - onResizeNudge, - onReviewChange, -}: AgentChatPanelProps) { - const { editor, currentNote } = useNotebookContext(); - // Decide which writing preset the empty chat screen offers, and what it - // calls the document: the notebook holds RFPs as well as proposals. - const noteIsEmpty = useEditorIsEmpty(editor); - const noteIsRfp = isRfpNote(currentNote); - - // One transport per note: the hooks reset on its identity. - const transport = getChatTransport({ noteId }); - const list = useAgentChatList(transport, open); - // Null is the new-chat screen, and it is where a page visit starts: the - // assistant opens on its own opening moves rather than dropping the reader - // into the middle of whatever they last asked. Earlier chats stay one click - // away in the picker, and a selection survives closing the panel — only a - // fresh visit or a note switch resets it. - const [selectedChatId, setSelectedChatId] = useState(null); - const switchChat = useCallback((nextChatId: number | null) => setSelectedChatId(nextChatId), []); - - // Network activity is gated on `open`. No keep-alive is needed for turns - // that finish while the panel is closed or another chat is selected: the - // note version socket below reports agent edits from any chat, and - // reopening (or reselecting) refetches the transcript. - const refreshList = list.refresh; - const onListStale = useCallback(() => { - if (open) refreshList(); - }, [open, refreshList]); - const onChatCreated = useCallback((created: AgentChat) => { - setSelectedChatId(created.conversation_id); - }, []); - const session = useChatSession({ - transport, - chatId: selectedChatId, - enabled: open, - onChatCreated, - onListStale, - notices: NOTICES, - }); - const { - chat: chatState, - modelSelection, - researchAI, - draft, - setDraft: updateDraft, - attachments, - notice, - clearNotice, - sendBlocked: budgetSendDisabled, - composerBusy, - canStop, - send, - } = session; - const hasModelSelection = canSelectAIModel(researchAI.budget?.tier); - const canSelectModel = hasModelSelection && researchAI.catalog !== null; - - const composerRef = useRef(null); - const panelRef = useRef(null); - - /** - * A preset loads the composer rather than sending: its message is a starting - * point, and the details that make it worth sending — the deadline, the - * section, the sub-field — are the user's to add. Focus follows the text so - * the caret is already waiting at the end of it. - */ - const applyPreset = useCallback( - (message: string) => { - clearNotice(); - updateDraft(message); - const textarea = composerRef.current; - if (!textarea) return; - textarea.focus(); - textarea.setSelectionRange(message.length, message.length); - }, - [clearNotice, updateDraft] - ); - - // ---- a note switch starts on the new-chat screen ---- - useEffect(() => { - setSelectedChatId(null); - }, [noteId]); - - // ---- server-side access gate ---- - const [deniedNoteId, setDeniedNoteId] = useState(null); - const accessNoteRef = useRef(noteId); - useEffect(() => { - if (accessNoteRef.current !== noteId) { - accessNoteRef.current = noteId; - setDeniedNoteId(null); - // The chat hooks reset after a note switch; their current access values - // can still belong to the previous note on this render. - return; - } - if (list.access === 'hidden' || chatState.access === 'unauthorized') { - setDeniedNoteId(noteId); - } - }, [noteId, list.access, chatState.access]); - const accessDenied = deniedNoteId === noteId; - - useEffect(() => { - // Leave a visible restriction until the user closes the panel. A blocked - // account keeps the entry point so its unavailable state remains reachable. - if ( - !open && - researchAI.budgetStatus !== 'loading' && - researchAI.budget?.tier !== 'blocked' && - accessDenied - ) { - onUnavailable(); - } - }, [open, accessDenied, onUnavailable, researchAI.budgetStatus, researchAI.budget?.tier]); - - // ---- rename ---- - const [renaming, setRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(''); - - const startRename = () => { - if (selectedChatId == null) return; - setRenameValue(chatState.chat?.title ?? ''); - setRenaming(true); - }; - - const sessionKeyRef = useRef(session.sessionKey); - sessionKeyRef.current = session.sessionKey; - const commitRename = async () => { - setRenaming(false); - const title = renameValue.trim(); - if (!title || title === (chatState.chat?.title ?? '')) return; - const key = session.sessionKey; - const renamed = await chatState.rename(title); - // A rename that raced a switch must not fire its note-bound refresh — the - // stale fetch would outrank and replace the current note's listing. - if (renamed && sessionKeyRef.current === key) refreshList(); - }; - - // A rename left open across a note switch would commit against whichever - // chat the new note auto-selects — it dies with the note that owned it. - useEffect(() => { - setRenaming(false); - }, [noteId]); - - // ---- assistant-version review of the note ---- - // The editor is the notebook's own; the hook keeps it in step with the - // versions the assistant writes and hands back the review to render. - const loadedNote = useMemo( - () => (currentNote ? { id: currentNote.id, versionId: currentNote.versionId } : null), - [currentNote] - ); - const noteReview = useNoteAgentReview({ - noteId, - editor, - loadedNote, - chat: chatState.chat, - onPersistEditorState, - }); - const { review, accept: acceptReview, reject: rejectReview } = noteReview; - - // The accept/reject controls render on the note page, next to the content - // they decide about — hand the host the current session, and null when it - // ends or this panel unmounts. - useEffect(() => { - if (!onReviewChange) return; - onReviewChange( - review == null - ? null - : { changeCount: review.changeCount, accept: acceptReview, reject: rejectReview } - ); - return () => onReviewChange(null); - }, [review, onReviewChange, acceptReview, rejectReview]); - - // ---- chat / sources tabs ---- - const sources = useMemo(() => collectChatSources(chatState.chat), [chatState.chat]); - const [activeTab, setActiveTab] = useState('chat'); - // A new chat starts with no citations, so a lingering Sources tab would open - // on an empty list. - useEffect(() => { - setActiveTab('chat'); - }, [selectedChatId, noteId]); - - // ---- transcript auto-scroll ---- - const { scrollRef, handleScroll, follow } = useJumpToLatest({ - resetKey: selectedChatId, - }); - useEffect(() => { - // The sources list shares this scroller; pinning it to the bottom on every - // transcript update would yank the citation the user is reading. - if (activeTab === 'chat') follow(); - }, [chatState.chat, chatState.pendingSend, activeTab, follow]); - - // ---- derived composer state ---- - const chatAccessible = selectedChatId == null ? list.access === 'ok' : chatState.access === 'ok'; - const composerDisabled = accessDenied || !chatAccessible; - - const emptyState = ( - - ); - - const renderBody = () => { - if ( - researchAI.budget?.tier === 'blocked' || - accessDenied || - list.access === 'hidden' || - chatState.access === 'unauthorized' - ) { - return ( - - You do not have access to the research assistant for this notebook. - - ); - } - if (selectedChatId == null) { - if (list.access === 'loading') return ; - if (list.access === 'error') { - return refreshList()} />; - } - return emptyState; - } - - switch (chatState.access) { - case 'loading': - return ; - case 'not_found': - return ( -
-

This chat is no longer available.

- -
- ); - case 'error': - return ( - chatState.refetch()} /> - ); - default: { - if (!chatState.chat) return ; - const isEmpty = - chatState.chat.messages.length === 0 && - chatState.chat.executions.length === 0 && - !chatState.pendingSend; - if (isEmpty) return emptyState; - return ; - } - } - }; - - return ( - - ); -} - -function TabButton({ - active, - onClick, - label, - count, -}: { - readonly active: boolean; - readonly onClick: () => void; - readonly label: string; - readonly count?: number; -}) { - return ( - - ); -} - -function CenteredLoader() { - return ( -
- -
- ); -} - -function ErrorState({ - message, - onRetry, -}: { - readonly message: string; - readonly onRetry: () => void; -}) { - return ( -
-

{message}

- -
- ); -} diff --git a/components/Notebook/LeftSidebar/NoteList.tsx b/components/Notebook/LeftSidebar/NoteList.tsx deleted file mode 100644 index 7ca854f00..000000000 --- a/components/Notebook/LeftSidebar/NoteList.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import { NoteListItem } from '@/components/Notebook/LeftSidebar/NoteListItem'; -import { isChangelogNote, type Note } from '@/types/note'; -import { NoteListSkeleton } from '@/components/skeletons/NoteListSkeleton'; -import { useTransition } from 'react'; -import { useUser } from '@/contexts/UserContext'; -import { useInView } from 'react-intersection-observer'; -import { useNotebookContext } from '@/contexts/NotebookContext'; - -interface NoteListProps { - notes: Note[]; - isLoading?: boolean; -} - -export const NoteList: React.FC = ({ notes, isLoading = false }) => { - const [isPending, startTransition] = useTransition(); - const { user } = useUser(); - const { hasMoreNotes, isLoadingMoreNotes, loadMoreNotes, notesError } = useNotebookContext(); - const isModerator = !!user?.isModerator; - - const { ref: sentinelRef } = useInView({ - threshold: 0, - rootMargin: '200px', - onChange: (inView) => { - if (inView) { - loadMoreNotes(); - } - }, - }); - - const filteredAndSortedNotes = notes - .filter( - (note) => - (note.access === 'WORKSPACE' || note.access === 'SHARED' || note.access === 'PRIVATE') && - (isModerator || !isChangelogNote(note)) - ) - .sort((a, b) => new Date(b.updatedDate).getTime() - new Date(a.updatedDate).getTime()); - - if (isLoading || notes.length === 0) { - return ; - } - - if (filteredAndSortedNotes.length === 0 && !hasMoreNotes) { - return ( -
-

No notes yet

-
- ); - } - - return ( -
- {filteredAndSortedNotes.map((note) => ( - - ))} - {isLoadingMoreNotes && } - {!isLoadingMoreNotes && - hasMoreNotes && - (notesError ? ( - - ) : ( - - ); -}; diff --git a/components/Notebook/LeftSidebar/NoteListItem.tsx b/components/Notebook/LeftSidebar/NoteListItem.tsx deleted file mode 100644 index 3bf6cc414..000000000 --- a/components/Notebook/LeftSidebar/NoteListItem.tsx +++ /dev/null @@ -1,153 +0,0 @@ -'use client'; - -import { File, MoreHorizontal, Copy, Trash2, Loader2 } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import { cn } from '@/utils/styles'; -import { useRouter } from 'next/navigation'; -import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu'; -import { useDeleteNote, useDuplicateNote } from '@/hooks/useNote'; -import type { Note } from '@/types/note'; -import toast from 'react-hot-toast'; -import { useState, useCallback, useRef, useEffect } from 'react'; -import { useNotebookContext } from '@/contexts/NotebookContext'; - -interface NoteListItemProps { - note: Note; - disabled: boolean; - startTransition: (callback: () => void) => void; -} - -export const NoteListItem = ({ note, disabled, startTransition }: NoteListItemProps) => { - const router = useRouter(); - const { refreshNotes, setNotes, activeNoteId } = useNotebookContext(); - const [{ isLoading: isDeleting }, deleteNote] = useDeleteNote(); - const [{ isLoading: isDuplicating }, duplicateNote] = useDuplicateNote(); - const [isMenuOpen, setIsMenuOpen] = useState(false); - const itemRef = useRef(null); - - const isProcessing = isDeleting || isDuplicating; - const isSelected = note.id.toString() === activeNoteId; - - useEffect(() => { - if (isSelected && itemRef.current) { - const timeoutId = setTimeout(() => { - itemRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }); - }, 100); - - return () => clearTimeout(timeoutId); - } - }, [isSelected]); - - const handleClick = useCallback(() => { - startTransition(() => { - router.push(`/notebook/${note.organization.slug}/${note.id}`, { scroll: false }); - }); - }, [router, note.organization.slug, note.id, startTransition]); - - const handleDuplicate = async (e: React.MouseEvent) => { - e.stopPropagation(); - try { - const newNote = await duplicateNote(note.id.toString(), note.organization.slug); - toast.success('Note duplicated successfully'); - refreshNotes(); - router.replace(`/notebook/${note.organization.slug}/${newNote.id}`); - } catch (error) { - console.error('Error duplicating note:', error); - toast.error('Failed to duplicate note. Please try again.'); - } - }; - - const handleDelete = async (e: React.MouseEvent) => { - e.stopPropagation(); - if ( - !globalThis.confirm( - 'Are you sure you want to delete this note? This action cannot be undone.' - ) - ) { - return; - } - try { - await deleteNote(note.id); - refreshNotes(); - setNotes((prevNotes) => prevNotes.filter((n) => n.id !== note.id)); - if (isSelected) { - router.replace(`/notebook/${note.organization.slug}`); - } - } catch (error: unknown) { - console.error('Error deleting note:', error); - toast.error('Failed to delete note. Please try again.'); - } - }; - - const menuTriggerButton = ( - - ); - - return ( -
- - -
- - -
- {isDuplicating ? ( - - ) : ( - - )} - {isDuplicating ? 'Duplicating...' : 'Duplicate'} -
-
- -
- {isDeleting ? ( - - ) : ( - - )} - {isDeleting ? 'Deleting...' : 'Delete'} -
-
-
-
-
- ); -}; diff --git a/components/Notebook/NoteCreationPopover.tsx b/components/Notebook/NoteCreationPopover.tsx deleted file mode 100644 index 2d753a12b..000000000 --- a/components/Notebook/NoteCreationPopover.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { ReactNode } from 'react'; - -interface NoteCreationPopoverProps { - isOpen: boolean; - children?: ReactNode; -} - -export const NoteCreationPopover = ({ isOpen, children }: NoteCreationPopoverProps) => { - if (!isOpen) return null; - - return ( -
-
-
-
-
-

Creating Note

-
-

Please wait...

-
-
-
- {children &&
{children}
} -
-
- ); -}; diff --git a/components/Notebook/NoteEditorLayout.tsx b/components/Notebook/NoteEditorLayout.tsx deleted file mode 100644 index 53ff1e17b..000000000 --- a/components/Notebook/NoteEditorLayout.tsx +++ /dev/null @@ -1,409 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef, useCallback } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { ArrowRight, Sparkles } from 'lucide-react'; -import { cn } from '@/utils/styles'; -import { Button } from '@/components/ui/Button'; - -import { BlockEditor } from '@/components/Editor/components/BlockEditor/BlockEditor'; -import { NotePaperWrapper } from './NotePaperWrapper'; -import { NotePaperSkeleton } from './NotePaperSkeleton'; -import { NotebookHome } from './NotebookHome'; -import { NotebookTour } from './NotebookTour'; -import { NotebookTabs, type NotebookTab } from './NotebookTabs'; -import { NotesMenu } from './NotesMenu'; -import { PublishedStatusSection } from './PublishingForm/components/PublishedStatusSection'; -import { PublishingForm, PublishingFormProvider } from '@/components/Notebook/PublishingForm'; - -import { ABOVE_MOBILE_NAV } from './mobileBarOffsets'; -import { AgentChatPanel, type NoteReviewHandle } from '@/components/Notebook/AgentChatPanel'; -import { noteDiffPersistableDoc } from './NoteReview/noteDiffOverlay'; -import { NoteReviewControls } from './NoteReview/NoteReviewControls'; -import { useNotebookContext } from '@/contexts/NotebookContext'; -import { useOrganizationContext } from '@/contexts/OrganizationContext'; -import { useUser } from '@/contexts/UserContext'; -import { useScreenSize } from '@/hooks/useScreenSize'; -import { useAgentChatWidth } from '@/hooks/useAgentChatWidth'; -import { useUpdateNote } from '@/hooks/useNote'; -import { useTopBarSlot } from '@/contexts/TopBarSlotContext'; -import { useDismissableFeature } from '@/hooks/useDismissableFeature'; -import { LegacyNoteBanner } from '@/components/LegacyNoteBanner'; -import { - getNoteKind, - isChangelogNote, - isPublishedRegisteredReportNote, - NOTE_KIND_LABELS, -} from '@/types/note'; - -// Persisted (per-user) flag so the guided tour auto-runs only once — the very -// first time someone lands in the editor on a freshly-created note. -const NOTEBOOK_TOUR_FEATURE = 'notebook_tour'; - -// Query params the note-creation flows append when redirecting to the editor. -// Their presence means the user just created this note (vs. opening an existing -// one), which is the only moment we want to auto-launch the tour. -const NEW_NOTE_PARAMS = ['newChangelog', 'newGrant', 'newFunding', 'template']; - -interface NoteEditorLayoutProps { - /** - * Fires when the assistant docks or undocks. Docking is decided here — it - * depends on the viewport, the access gate and the note — but the page - * container above has to widen in step, or its centring margins strand a - * band of empty space beside the shrunken document. - */ - readonly onAgentChatDockedChange?: (docked: boolean) => void; -} - -export function NoteEditorLayout({ onAgentChatDockedChange }: NoteEditorLayoutProps = {}) { - const { - currentNote: note, - isLoadingNote, - noteError, - setEditor, - updateNoteTitle, - saveDetailsSoon, - activeNoteId, - editor, - } = useNotebookContext(); - - const { selectedOrg } = useOrganizationContext(); - const { user, isLoading: isLoadingUser } = useUser(); - const { lgAndUp, xlAndUp } = useScreenSize(); - const isDesktop = lgAndUp; - - const topBarSlot = useTopBarSlot(); - const setLeftSlot = topBarSlot?.setLeftSlot; - const searchParams = useSearchParams(); - - const { - isDismissed: isTourDismissed, - dismissFeature: dismissTour, - dismissStatus: tourDismissStatus, - } = useDismissableFeature(NOTEBOOK_TOUR_FEATURE); - - const [isLegacyNote, setIsLegacyNote] = useState(undefined); - const [activeTab, setActiveTab] = useState(() => - searchParams?.get('tab') === 'details' ? 'details' : 'document' - ); - - // ---- AI assistant chat ---- - const [isAgentChatOpen, setIsAgentChatOpen] = useState(false); - // Flipped when the server denies access (the gate can change server-side); - // hides the entry point while this note is open. - const [agentChatUnavailable, setAgentChatUnavailable] = useState(false); - // Active in-note review of an assistant version: the panel drives the - // overlay, this layout renders the accept/restore controls over the note. - const [agentReview, setAgentReview] = useState(null); - - const handleAgentChatUnavailable = useCallback(() => { - setAgentChatUnavailable(true); - setIsAgentChatOpen(false); - }, []); - - // The server's denial is note/hub-scoped and this layout survives note - // navigation — re-arm the entry point on the next note so a denial in one - // hub doesn't permanently hide the assistant in hubs the user does edit. - useEffect(() => { - setAgentChatUnavailable(false); - }, [activeNoteId]); - - const { - width: agentChatWidth, - isResizing: isAgentChatResizing, - startResize: startAgentChatResize, - nudgeWidth: nudgeAgentChatWidth, - } = useAgentChatWidth(); - - const isChangelog = isChangelogNote(note); - const isChangelogAccessDenied = isChangelog && !user?.isModerator; - - const showAgentChat = - Boolean(user) && - !agentChatUnavailable && - // Changelogs are moderator-only: the page renders Note Not Found in place - // of the document, so the assistant must not mount over it. - !isChangelogAccessDenied && - Boolean(activeNoteId) && - Boolean(note) && - !noteError && - isLegacyNote === false; - - // Docking splits the viewport: the panel takes its own column and the - // document gives up the same gutter. Below xl there isn't enough room left - // to keep the document readable, so the panel covers it as a sheet instead. - const isAgentChatDocked = showAgentChat && isAgentChatOpen && xlAndUp === true; - const isUndockedChatOpen = showAgentChat && isAgentChatOpen && !isAgentChatDocked; - - useEffect(() => { - onAgentChatDockedChange?.(isAgentChatDocked); - }, [isAgentChatDocked, onAgentChatDockedChange]); - - // Unmounting with the panel open would otherwise leave the container wide. - useEffect(() => () => onAgentChatDockedChange?.(false), [onAgentChatDockedChange]); - - const previousNoteId = useRef(activeNoteId); - const [isTourOpen, setIsTourOpen] = useState(false); - const tourAutoStarted = useRef(false); - - const isNewlyCreatedNote = NEW_NOTE_PARAMS.some((param) => searchParams?.has(param)); - - useEffect(() => { - if (previousNoteId.current !== activeNoteId) { - setActiveTab('document'); - previousNoteId.current = activeNoteId; - } - }, [activeNoteId]); - - // Surface the "Notebook" notes dropdown in the shared TopBar's left area. - useEffect(() => { - if (!setLeftSlot) return; - setLeftSlot(); - return () => setLeftSlot(null); - }, [setLeftSlot]); - - // Auto-start the tour exactly once, the first time a user opens a note they - // just created. We wait until the note is fully set up (loaded + editor - // mounted) so the highlight target already has its final size/position — - // opening earlier anchors to the skeleton and the popover jumps when the real - // content swaps in. - const isNoteReady = !isLoadingNote && Boolean(note) && isLegacyNote === false && Boolean(editor); - useEffect(() => { - if (isDesktop !== true || tourAutoStarted.current) return; - if (tourDismissStatus !== 'checked' || isTourDismissed) return; - if (!isNewlyCreatedNote) return; - if (!isNoteReady) return; - tourAutoStarted.current = true; - setIsTourOpen(true); - dismissTour(); - }, [isDesktop, isNoteReady, tourDismissStatus, isTourDismissed, isNewlyCreatedNote, dismissTour]); - - useEffect(() => { - if (isLoadingNote) return; - if (!note || noteError) { - setIsLegacyNote(false); - return; - } - setIsLegacyNote(!note.contentJson); - }, [note, noteError, isLoadingNote]); - - const [, updateNote, saveNoteNow] = useUpdateNote(note?.id, { - saveTitle: (title) => saveDetailsSoon({ title }), - onTitleUpdate: updateNoteTitle, - registeredReportProposalId: note?.proposalId, - // While an assistant review is open the editor holds a merged document; - // saves must persist it without the struck (pending-removal) ranges. - docToPersist: (editorInstance) => - noteDiffPersistableDoc(editorInstance) ?? editorInstance.state.doc, - }); - - // The panel asks for the editor's current document to be persisted as the - // newest server version: the user chose "Keep mine" over an assistant - // version, or a reload applied an assistant version that newer saves had - // buried. Applying content programmatically emits no editor update, so - // without this save the editor and the server would silently diverge and - // the choice would vanish on the next load. The save runs immediately (a - // debounced one could be cancelled by navigation before it fires) and the - // result tells the panel whether the choice actually became durable. - const handlePersistEditorState = useCallback(async () => { - if (!editor || editor.isDestroyed) return false; - return saveNoteNow(editor); - }, [editor, saveNoteNow]); - - const showTabs = Boolean(note) && !isLegacyNote && !isChangelogAccessDenied; - const isPublishedRegisteredReport = isPublishedRegisteredReportNote(note); - const isEditorReadOnly = isPublishedRegisteredReport || isLegacyNote; - const noteKind = getNoteKind(note); - const workTypeLabel = isChangelog - ? 'ChangeLog' - : noteKind === 'other' - ? undefined - : NOTE_KIND_LABELS[noteKind]; - - const renderEditor = () => { - // No note is targeted (notebook home) — render the landing view directly so - // the document skeleton doesn't flash before the empty state resolves. - if (!activeNoteId) { - return ; - } - - if ((isChangelog && isLoadingUser) || isLoadingNote || isLegacyNote === undefined) { - return ; - } - - if ((noteError && activeNoteId) || isChangelogAccessDenied) { - return ( - -
-
-

Note Not Found

-

- The note you're looking for doesn't exist or you don't have - permission to view it. -

-
-
-
- ); - } - - if (!note) { - return ; - } - - return ( - - ) : undefined - } - > - {/* Work type + draft status pinned to the document's top-left corner. */} -
- {workTypeLabel && ( - {workTypeLabel} - )} - -
- -
- ); - }; - - if (isDesktop === null) return null; - - return ( -
-
- {showTabs && ( -
- {isPublishedRegisteredReport && ( -
- This Registered Report has been published and can no longer be edited. -
- )} -
- -
- {activeTab === 'document' && ( - - )} -
-
-
- )} - -
{renderEditor()}
- {showTabs && ( -
- - - -
- )} - - {isDesktop && setIsTourOpen(false)} />} -
- - {/* Floating over the document — but only while the document is visible. - Docked, the panel sits beside it and the controls shift by the panel's - width; undocked, the panel covers the document and carries its own - copy of these controls, so this one stands down. */} - {agentReview && (!showTabs || activeTab === 'document') && !isUndockedChatOpen && ( -
- -
- )} - - {/* - * Floats in the bottom-right corner, on the side the panel docks to. - * Below `tablet` it clears the 64px MobileBottomNav. It hides once open — - * the panel would cover it, and its close button is the way back. - */} - {showAgentChat && activeNoteId && !isAgentChatOpen && ( - - )} - - {showAgentChat && activeNoteId && ( - setIsAgentChatOpen(false)} - onUnavailable={handleAgentChatUnavailable} - onPersistEditorState={handlePersistEditorState} - onReviewChange={setAgentReview} - docked={isAgentChatDocked} - width={agentChatWidth} - isResizing={isAgentChatResizing} - onResizeStart={startAgentChatResize} - onResizeNudge={nudgeAgentChatWidth} - /> - )} -
- ); -} diff --git a/components/Notebook/NotePaperSkeleton.tsx b/components/Notebook/NotePaperSkeleton.tsx deleted file mode 100644 index 2425536ea..000000000 --- a/components/Notebook/NotePaperSkeleton.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { NotebookSkeleton } from '@/components/skeletons/NotebookSkeleton'; -import { NotePaperWrapper } from './NotePaperWrapper'; - -export const NotePaperSkeleton = () => { - return ( - - - - ); -}; diff --git a/components/Notebook/NotePaperWrapper.tsx b/components/Notebook/NotePaperWrapper.tsx deleted file mode 100644 index 79b762aca..000000000 --- a/components/Notebook/NotePaperWrapper.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { ReactNode } from 'react'; -import { cn } from '@/utils/styles'; - -/** Shared editor paper card — provides the left gutter (pl-16) and top padding. */ -interface NotePaperProps extends React.HTMLAttributes { - children: ReactNode; - className?: string; - minHeight?: string; -} - -export const NotePaper = ({ - children, - className, - minHeight = '800px', - ...rest -}: NotePaperProps) => { - return ( -
- {children} -
- ); -}; - -/** Full-page wrapper for notebook routes. Adds gray background, shadow, and optional banner. */ -interface NotePaperWrapperProps { - children: ReactNode; - className?: string; - showBanner?: ReactNode; - /** - * When false, render just the white paper card (no gray canvas / centering), - * letting the surrounding page supply the gray background. Used in the - * shared page layout where the scroll area is already gray. - */ - canvas?: boolean; -} - -export const NotePaperWrapper = ({ - children, - className = '', - showBanner, - canvas = true, -}: NotePaperWrapperProps) => { - const content = ( - <> - {showBanner && ( - - {showBanner} - - )} - - {children} - - - ); - - if (!canvas) { - return content; - } - - return ( -
-
-
{content}
-
-
- ); -}; diff --git a/components/Notebook/NotebookHome.tsx b/components/Notebook/NotebookHome.tsx deleted file mode 100644 index 238146bd7..000000000 --- a/components/Notebook/NotebookHome.tsx +++ /dev/null @@ -1,149 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { ArrowRight } from 'lucide-react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faBullhorn, faFileSignature } from '@fortawesome/pro-light-svg-icons'; -import { NoteList } from '@/components/Notebook/LeftSidebar/NoteList'; -import { useNotebookContext } from '@/contexts/NotebookContext'; -import { - OpenFundingOpportunityModal, - type FundingOpportunityCreationMethod, -} from '@/components/Funding/OpenFundingOpportunityModal'; -import { - OpenProposalModal, - type ProposalCreationMethod, -} from '@/components/Funding/OpenProposalModal'; -import Icon from '@/components/ui/icons/Icon'; -import { NotePaperWrapper } from './NotePaperWrapper'; -import { useUser } from '@/contexts/UserContext'; - -interface CreateOption { - id: string; - title: string; - description: string; - icon: React.ReactNode; - onClick: () => void; -} - -/** - * Landing view for the notebook when no specific note is open. Mirrors the - * "Notebook" dropdown: surfaces the funding-opportunity / proposal creation - * flows alongside the user's existing files. - */ -export function NotebookHome() { - const router = useRouter(); - const { notes, isLoading: isLoadingNotes } = useNotebookContext(); - const { user } = useUser(); - const isModerator = !!user?.isModerator; - - const [isFundingOpportunityModalOpen, setIsFundingOpportunityModalOpen] = useState(false); - const [isProposalModalOpen, setIsProposalModalOpen] = useState(false); - - // Mirror PublishMenu / LeftSidebar: the modal picks a creation method, then we - // hand off to the notebook page which scaffolds the document from it. - const handleConfirmOpenGrant = (method: FundingOpportunityCreationMethod) => { - setIsFundingOpportunityModalOpen(false); - router.push(`/notebook?newGrant=true&grantSource=${method}`); - }; - - const handleConfirmCreateProposal = (method: ProposalCreationMethod) => { - setIsProposalModalOpen(false); - router.push(`/notebook?newFunding=true&proposalSource=${method}`); - }; - - const createOptions: CreateOption[] = [ - { - id: 'funding-opportunity', - title: 'Request for Proposal', - description: 'Fund specific research you care about', - icon: , - onClick: () => setIsFundingOpportunityModalOpen(true), - }, - { - id: 'proposal', - title: 'Proposal', - description: 'Raise money for your research', - icon: , - onClick: () => setIsProposalModalOpen(true), - }, - ...(isModerator - ? [ - { - id: 'changelog', - title: 'ChangeLog', - description: 'Publish a ResearchHub product update', - icon: , - onClick: () => router.push('/notebook?newChangelog=true'), - }, - ] - : []), - ]; - - const hasNotes = notes?.some( - (note) => note.access === 'WORKSPACE' || note.access === 'SHARED' || note.access === 'PRIVATE' - ); - - return ( - -
-
-

My Notebook

-

Start something new or jump back into your work.

-
- - {/* Create */} -
- {createOptions.map((option) => ( - - ))} -
- - {/* Files */} -
-

- Your files -

- {hasNotes || isLoadingNotes ? ( - - ) : ( -
- No files yet. Create an RFP or proposal to get started. -
- )} -
-
- - setIsFundingOpportunityModalOpen(false)} - onConfirm={handleConfirmOpenGrant} - minimal - /> - - setIsProposalModalOpen(false)} - onConfirm={handleConfirmCreateProposal} - minimal - /> -
- ); -} diff --git a/components/Notebook/NotebookPrimaryNavigation.tsx b/components/Notebook/NotebookPrimaryNavigation.tsx deleted file mode 100644 index 01cac0083..000000000 --- a/components/Notebook/NotebookPrimaryNavigation.tsx +++ /dev/null @@ -1,138 +0,0 @@ -'use client'; - -import { NoteList } from '@/components/Notebook/LeftSidebar/NoteList'; -import { useOrganizationContext } from '@/contexts/OrganizationContext'; -import { UserPlus } from 'lucide-react'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faBullhorn, faFileSignature } from '@fortawesome/pro-light-svg-icons'; -import { useNotebookContext } from '@/contexts/NotebookContext'; -import Icon from '@/components/ui/icons/Icon'; -import { useUser } from '@/contexts/UserContext'; - -export const NOTEBOOK_WORK_TYPES = [ - { - value: 'grant', - label: 'RFP', - description: 'Offer funding for research applications.', - }, - { - value: 'preregistration', - label: 'Proposal', - description: 'Create a research proposal and optionally raise funding.', - }, - { - value: 'discussion', - label: 'ChangeLog', - description: 'Publish a ResearchHub product update.', - }, -] as const; - -export const getAvailableNotebookWorkTypes = (isModerator: boolean) => - NOTEBOOK_WORK_TYPES.filter((workType) => workType.value !== 'discussion' || isModerator); - -type NotebookWorkType = (typeof NOTEBOOK_WORK_TYPES)[number]['value']; - -const SectionHeading = ({ children }: { children: React.ReactNode }) => ( -

- {children} -

-); - -interface NotebookPrimaryNavigationProps { - onNewFundingOpportunity: () => void; - onNewProposal: () => void; - onNewChangelog: () => void; - onInvitePeople: () => void; -} - -/** - * Body of the notebook's primary navigation menu (the "My Notebook" dropdown on - * desktop / full-screen sheet on mobile). Surfaces document-creation entry - * points, the user's files, and workspace actions. - * - * Document-creation actions are delegated to the parent (`NotesMenu`) so it can - * close the menu before opening a modal or navigating — the modals live there - * because this component unmounts when the menu closes. - */ -export const NotebookPrimaryNavigation = ({ - onNewFundingOpportunity, - onNewProposal, - onNewChangelog, - onInvitePeople, -}: NotebookPrimaryNavigationProps) => { - const { selectedOrg } = useOrganizationContext(); - const { notes, isLoading: isLoadingNotes } = useNotebookContext(); - const { user } = useUser(); - - const isCurrentUserAdmin = selectedOrg?.userPermission?.accessType === 'ADMIN'; - const isModerator = !!user?.isModerator; - - const hasNotes = notes?.some( - (note) => note.access === 'WORKSPACE' || note.access === 'SHARED' || note.access === 'PRIVATE' - ); - const createActions: Record void }> = { - grant: { - icon: , - onClick: onNewFundingOpportunity, - }, - preregistration: { - icon: , - onClick: onNewProposal, - }, - discussion: { - icon: , - onClick: onNewChangelog, - }, - }; - const availableWorkTypes = getAvailableNotebookWorkTypes(isModerator); - - return ( -
- {/* Create */} - Create - {availableWorkTypes.map((workType) => { - const action = createActions[workType.value]; - return ( - - ); - })} - -
- - {/* Files */} - Files -
- {hasNotes || isLoadingNotes ? ( - - ) : ( -
No notes yet
- )} -
- -
- - {/* Workspace */} - Workspace - -
- ); -}; diff --git a/components/Notebook/NotebookTabs.tsx b/components/Notebook/NotebookTabs.tsx deleted file mode 100644 index 225fe9816..000000000 --- a/components/Notebook/NotebookTabs.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client'; - -import { ArrowRight } from 'lucide-react'; -import { cn } from '@/utils/styles'; - -export type NotebookTab = 'document' | 'details'; - -interface NotebookTabsProps { - active: NotebookTab; - onChange: (tab: NotebookTab) => void; -} - -const STEPS: { id: NotebookTab; label: string; dataTour?: string }[] = [ - { id: 'document', label: 'Document' }, - { id: 'details', label: 'Details', dataTour: 'notebook-publish' }, -]; - -/** Linear two-step flow shown in the notebook top bar. It reads as a sequence - * (Document → Details) rather than parallel tabs, so the progression toward - * publishing is visually explicit. */ -export function NotebookTabs({ active, onChange }: NotebookTabsProps) { - const activeIndex = STEPS.findIndex((step) => step.id === active); - - return ( - - ); -} - -type StepState = 'current' | 'complete' | 'upcoming'; - -function Step({ - index, - label, - state, - onClick, - dataTour, -}: Readonly<{ - index: number; - label: string; - state: StepState; - onClick: () => void; - dataTour?: string; -}>) { - return ( - - ); -} diff --git a/components/Notebook/NotebookTour.tsx b/components/Notebook/NotebookTour.tsx deleted file mode 100644 index d86695689..000000000 --- a/components/Notebook/NotebookTour.tsx +++ /dev/null @@ -1,200 +0,0 @@ -'use client'; - -import { useEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { RadiatingDot } from '@/components/ui/RadiatingDot'; -import { cn } from '@/utils/styles'; - -type Placement = 'top' | 'bottom' | 'left' | 'right' | 'center'; - -interface TourStep { - selector: string; - title: string; - description: string; - placement: Placement; -} - -// Steps are matched to live DOM via `data-tour` anchors. If a target isn't -// present (e.g. the publishing sidebar before a note is opened), the step -// gracefully falls back to a centered popover with no spotlight. -const STEPS: TourStep[] = [ - { - selector: '[data-tour="notebook-editor"]', - title: 'Write in the main area', - description: - 'This is your document. Draft your document here. Everything autosaves as you type.', - placement: 'center', - }, - { - selector: '[data-tour="notebook-publish"]', - title: 'Add details & publish', - description: - 'Switch to the Details tab to set attributes of your document. Once it looks good, click the Publish button.', - placement: 'bottom', - }, -]; - -const SPOTLIGHT_PADDING = 8; -const POPOVER_WIDTH = 320; - -interface NotebookTourProps { - run: boolean; - onClose: () => void; -} - -export function NotebookTour({ run, onClose }: NotebookTourProps) { - const [stepIndex, setStepIndex] = useState(0); - const [rect, setRect] = useState(null); - const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null); - const popoverRef = useRef(null); - - const step = STEPS[stepIndex]; - - // Restart from the first step each time the tour is (re)opened. - useEffect(() => { - if (run) setStepIndex(0); - }, [run]); - - // Continuously track the target while the tour is open. The notebook layout - // animates (the right sidebar slides in via a grid-template-columns - // transition, content reflows on load), so a one-time measurement lands the - // highlight out of place. Measuring every frame keeps the ring glued to the - // target through any reflow; we only re-render when the rect actually moves. - useEffect(() => { - if (!run) return; - let raf = 0; - let prevKey = ''; - const tick = () => { - const el = document.querySelector(step.selector) as HTMLElement | null; - const r = el ? el.getBoundingClientRect() : null; - const key = r - ? `${Math.round(r.top)}:${Math.round(r.left)}:${Math.round(r.width)}:${Math.round(r.height)}` - : 'none'; - if (key !== prevKey) { - prevKey = key; - setRect(r); - } - raf = requestAnimationFrame(tick); - }; - raf = requestAnimationFrame(tick); - return () => cancelAnimationFrame(raf); - }, [run, step]); - - // Position the popover relative to the target (or centered as a fallback). - useEffect(() => { - if (!run) return; - const w = POPOVER_WIDTH; - const h = popoverRef.current?.offsetHeight ?? 190; - const vw = window.innerWidth; - const vh = window.innerHeight; - const margin = 16; - - let top: number; - let left: number; - - if (step.placement === 'center' && rect) { - // Center the popover over the highlighted target rather than the viewport. - top = rect.top + rect.height / 2 - h / 2; - left = rect.left + rect.width / 2 - w / 2; - } else if (!rect || step.placement === 'center') { - top = vh / 2 - h / 2; - left = vw / 2 - w / 2; - } else if (step.placement === 'bottom') { - top = rect.bottom + 12; - left = rect.left + rect.width / 2 - w / 2; - } else if (step.placement === 'top') { - top = rect.top - h - 12; - left = rect.left + rect.width / 2 - w / 2; - } else if (step.placement === 'left') { - left = rect.left - w - 12; - top = rect.top + rect.height / 2 - h / 2; - } else { - left = rect.right + 12; - top = rect.top + rect.height / 2 - h / 2; - } - - left = Math.min(Math.max(left, margin), vw - w - margin); - top = Math.min(Math.max(top, margin), vh - h - margin); - setPopoverPos({ top, left }); - }, [run, rect, step, stepIndex]); - - if (!run || typeof document === 'undefined') return null; - - const isLast = stepIndex === STEPS.length - 1; - const handleNext = () => (isLast ? onClose() : setStepIndex((i) => i + 1)); - const handleBack = () => setStepIndex((i) => Math.max(0, i - 1)); - - const spotlight = rect - ? { - top: rect.top - SPOTLIGHT_PADDING, - left: rect.left - SPOTLIGHT_PADDING, - width: rect.width + SPOTLIGHT_PADDING * 2, - height: rect.height + SPOTLIGHT_PADDING * 2, - } - : null; - - return createPortal( -
- {spotlight && ( -
- )} - -
-
-
- -

{step.title}

-
-

{step.description}

- -
- -
- {stepIndex > 0 && ( - - )} - -
-
-
-
-
, - document.body - ); -} diff --git a/components/Notebook/NotesMenu.tsx b/components/Notebook/NotesMenu.tsx deleted file mode 100644 index 2c338a465..000000000 --- a/components/Notebook/NotesMenu.tsx +++ /dev/null @@ -1,206 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { usePathname, useRouter } from 'next/navigation'; -import { ChevronDown, X } from 'lucide-react'; -import { NotebookPrimaryNavigation } from '@/components/Notebook/NotebookPrimaryNavigation'; -import { OrganizationSettingsModal } from '@/components/modals/OrganizationSettingsModal'; -import { - OpenFundingOpportunityModal, - type FundingOpportunityCreationMethod, -} from '@/components/Funding/OpenFundingOpportunityModal'; -import { - OpenProposalModal, - type ProposalCreationMethod, -} from '@/components/Funding/OpenProposalModal'; -import { useOrganizationContext } from '@/contexts/OrganizationContext'; -import Icon from '@/components/ui/icons/Icon'; -import { useScreenSize } from '@/hooks/useScreenSize'; -import { cn } from '@/utils/styles'; - -/** - * Compact replacement for the persistent notebook left sidebar. Lives in the - * editor top bar and reveals the note browser/creator in a dropdown so the - * horizontal space can go to the (more important) publishing sidebar. - * - * On mobile it expands to a full-screen sheet. That sheet is rendered through a - * portal because the top bar uses a CSS transform (for its show/hide - * animation), which would otherwise trap a `position: fixed` child inside the - * thin top-bar box instead of letting it cover the viewport. - */ -export function NotesMenu() { - const [isOpen, setIsOpen] = useState(false); - const [isMounted, setIsMounted] = useState(false); - // Bottom inset so the mobile sheet stops above the (fixed) bottom nav rather - // than covering it. The sheet intentionally covers the top bar. - const [bottomInset, setBottomInset] = useState(0); - // The creation modals live here (rather than inside NotebookPrimaryNavigation) - // so the menu can close as the modal opens without unmounting the modal. - const [isFundingOpportunityModalOpen, setIsFundingOpportunityModalOpen] = useState(false); - const [isProposalModalOpen, setIsProposalModalOpen] = useState(false); - const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); - const pathname = usePathname(); - const router = useRouter(); - const { smAndDown } = useScreenSize(); - const { selectedOrg } = useOrganizationContext(); - - const isCurrentUserAdmin = selectedOrg?.userPermission?.accessType === 'ADMIN'; - - // Mirror PublishMenu: the modal picks a creation method, then we hand off to - // the notebook page which scaffolds the document from that method. - const handleConfirmOpenGrant = (method: FundingOpportunityCreationMethod) => { - setIsFundingOpportunityModalOpen(false); - router.push(`/notebook?newGrant=true&grantSource=${method}`); - }; - - const handleConfirmCreateProposal = (method: ProposalCreationMethod) => { - setIsProposalModalOpen(false); - router.push(`/notebook?newFunding=true&proposalSource=${method}`); - }; - - useEffect(() => { - setIsMounted(true); - }, []); - - // Close when navigating to/creating a note (NoteListItem pushes routes - // directly, so we key off the pathname instead of an explicit callback). - useEffect(() => { - setIsOpen(false); - }, [pathname]); - - useEffect(() => { - if (!isOpen) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setIsOpen(false); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [isOpen]); - - const isMobile = smAndDown === true; - - // Each creation action closes the menu first, then opens its modal / navigates. - const handleNewFundingOpportunity = () => { - setIsOpen(false); - setIsFundingOpportunityModalOpen(true); - }; - - const handleNewProposal = () => { - setIsOpen(false); - setIsProposalModalOpen(true); - }; - - const handleNewChangelog = () => { - setIsOpen(false); - router.push('/notebook?newChangelog=true'); - }; - - const handleInvitePeople = () => { - setIsOpen(false); - setIsSettingsModalOpen(true); - }; - - const navigation = ( - - ); - - // Measure the bottom nav while the mobile sheet is open so it stops just - // above it (accounts for safe areas). - useEffect(() => { - if (!isOpen || !isMobile) return; - const measure = () => { - const bottomNav = document.querySelector('[data-mobile-bottom-nav]'); - setBottomInset(bottomNav?.getBoundingClientRect().height ?? 0); - }; - measure(); - window.addEventListener('resize', measure); - return () => window.removeEventListener('resize', measure); - }, [isOpen, isMobile]); - - return ( -
- - - {/* Desktop: anchored dropdown rendered inline next to the trigger. */} - {isOpen && !isMobile && ( - <> - {/* Click-catcher sits below the panel and any nested menus/modals. */} -
setIsOpen(false)} aria-hidden /> -
- {navigation} -
- - )} - - {/* Mobile: sheet portaled to so it escapes the top bar's - transformed stacking context. It covers the top bar (z above its - z-[60]) and stops just above the fixed bottom nav. */} - {isOpen && - isMobile && - isMounted && - createPortal( -
- {/* Header with a close control. */} -
-
- - My Notebook -
- -
- -
{navigation}
-
, - document.body - )} - - setIsFundingOpportunityModalOpen(false)} - onConfirm={handleConfirmOpenGrant} - minimal - /> - - setIsProposalModalOpen(false)} - onConfirm={handleConfirmCreateProposal} - minimal - /> - - {selectedOrg && isCurrentUserAdmin && ( - setIsSettingsModalOpen(false)} - /> - )} -
- ); -} diff --git a/components/Notebook/PublishingForm/PublishButton.tsx b/components/Notebook/PublishingForm/PublishButton.tsx deleted file mode 100644 index 07fd14e84..000000000 --- a/components/Notebook/PublishingForm/PublishButton.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client'; - -import type { ComponentProps } from 'react'; -import { Button } from '@/components/ui/Button'; -import { cn } from '@/utils/styles'; -import { usePublishingCompletion } from './completion'; -import { usePublishingController, type PublishingController } from './PublishingFormProvider'; - -const publishLabel = ({ - readOnly, - isUpserting, - isLinkingNonprofit, - isRedirecting, - isPublished, -}: PublishingController): string => { - if (readOnly) return 'Published'; - if (isUpserting) return 'Publishing...'; - if (isLinkingNonprofit) return 'Linking nonprofit...'; - if (isRedirecting) return 'Redirecting...'; - return isPublished ? 'Update' : 'Publish'; -}; - -interface PublishButtonProps { - /** - * Stay disabled until every required detail is filled in. Without it the - * click validates and names what is missing, which suits a form the user - * is looking at; with it the button reads as the last step of a checklist. - */ - readonly requireComplete?: boolean; - readonly size?: ComponentProps['size']; - readonly className?: string; -} - -/** Starts the publish flow for the note the surrounding provider holds. */ -export function PublishButton({ requireComplete = false, size, className }: PublishButtonProps) { - const controller = usePublishingController(); - const { isComplete } = usePublishingCompletion(); - const blockedByChecklist = requireComplete && !isComplete && !controller.isPublished; - - return ( - - ); -} diff --git a/components/Notebook/PublishingForm/PublishingFormProvider.tsx b/components/Notebook/PublishingForm/PublishingFormProvider.tsx index 9f942f325..eb7dc64c9 100644 --- a/components/Notebook/PublishingForm/PublishingFormProvider.tsx +++ b/components/Notebook/PublishingForm/PublishingFormProvider.tsx @@ -33,7 +33,7 @@ import { type PublishAction, } from './usePublishAction'; -export interface PublishingController extends PublishAction { +interface PublishingController extends PublishAction { readonly note: NoteWithContent | null; readonly editor: Editor | null; /** The form can be read but not changed or published. */ diff --git a/components/Notebook/PublishingForm/components/AuthorsSection.tsx b/components/Notebook/PublishingForm/components/AuthorsSection.tsx deleted file mode 100644 index bedf15a85..000000000 --- a/components/Notebook/PublishingForm/components/AuthorsSection.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Users } from 'lucide-react'; -import { SectionHeader } from './SectionHeader'; -import { cn } from '@/utils/styles'; -import type { SectionProps } from './SectionProps'; -import { useFormContext } from 'react-hook-form'; -import { getFieldErrorMessage } from '@/utils/form'; -import { SearchableUserSelect } from '@/components/ui/form/SearchableUserSelect'; - -export function AuthorsSection({ className }: SectionProps) { - const { - watch, - setValue, - formState: { errors }, - } = useFormContext(); - - const authors = watch('authors') || []; - - return ( -
- Authors - setValue('authors', newAuthors, { shouldValidate: true })} - placeholder="Search for authors..." - helperText="Drag and drop authors to arrange them in the order they should appear." - error={getFieldErrorMessage(errors.authors, 'Invalid authors')} - /> -
- ); -} diff --git a/components/Notebook/PublishingForm/components/ContactsSection.tsx b/components/Notebook/PublishingForm/components/ContactsSection.tsx deleted file mode 100644 index f6ee4ccbc..000000000 --- a/components/Notebook/PublishingForm/components/ContactsSection.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Users } from 'lucide-react'; -import { SectionHeader } from './SectionHeader'; -import { cn } from '@/utils/styles'; -import type { SectionProps } from './SectionProps'; -import { useFormContext } from 'react-hook-form'; -import { getFieldErrorMessage } from '@/utils/form'; -import { SearchableUserSelect } from '@/components/ui/form/SearchableUserSelect'; -import { UserSuggestion } from '@/types/search'; - -const getContactId = (user: UserSuggestion) => user.id!.toString(); - -export function ContactsSection({ className }: SectionProps) { - const { - watch, - setValue, - formState: { errors }, - } = useFormContext(); - - const contacts = watch('contacts') || []; - - return ( -
- Contacts - setValue('contacts', newContacts, { shouldValidate: true })} - placeholder="Search for contacts..." - error={getFieldErrorMessage(errors.contacts, 'Invalid contacts')} - helperText="Add contacts who will be responsible for managing this RFP. These contacts will receive important updates about its progress." - getOptionValue={getContactId} - /> -
- ); -} diff --git a/components/Notebook/PublishingForm/components/EndDateSection.tsx b/components/Notebook/PublishingForm/components/EndDateSection.tsx index 7ebd151bb..faf9f4d36 100644 --- a/components/Notebook/PublishingForm/components/EndDateSection.tsx +++ b/components/Notebook/PublishingForm/components/EndDateSection.tsx @@ -1,44 +1,3 @@ -import { useFormContext } from 'react-hook-form'; -import { ChevronDown } from 'lucide-react'; - /** What a fundraise's duration means; shared with the workspace's publish dialog. */ export const FUNDRAISE_DURATION_HELP = 'Your proposal will stop accepting contributions after this period.'; - -const END_DATE_OPTIONS = [ - { value: '30', label: '30 days' }, - { value: '60', label: '60 days' }, - { value: '90', label: '90 days' }, -] as const; - -export function EndDateSection() { - const { watch, setValue } = useFormContext(); - const fundraiseEndDays = watch('fundraiseEndDays') ?? '90'; - - return ( -
-

- Fundraise Duration -

-
- - -
-

{FUNDRAISE_DURATION_HELP}

-
- ); -} diff --git a/components/Notebook/PublishingForm/components/FundingGoalField.tsx b/components/Notebook/PublishingForm/components/FundingGoalField.tsx deleted file mode 100644 index d89397aa0..000000000 --- a/components/Notebook/PublishingForm/components/FundingGoalField.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useFormContext } from 'react-hook-form'; -import { Input } from '@/components/ui/form/Input'; -import type { PublishingFormData } from '../schema'; - -/** The total a proposal asks funders for, in whole US dollars. */ -export function FundingGoalField() { - const { - register, - setValue, - formState: { errors }, - } = useFormContext(); - - return ( - USD
- } - helperText="Set your total funding goal for this research project" - onChange={(e) => { - const numericValue = e.target.value.replaceAll(/\D/g, ''); - setValue('budget', numericValue, { shouldValidate: true }); - - if (numericValue) { - e.target.value = new Intl.NumberFormat('en-US').format(Number.parseInt(numericValue)); - } else { - e.target.value = ''; - } - }} - /> - ); -} diff --git a/components/Notebook/PublishingForm/components/FundingOpportunityField.tsx b/components/Notebook/PublishingForm/components/FundingOpportunityField.tsx deleted file mode 100644 index c7844115e..000000000 --- a/components/Notebook/PublishingForm/components/FundingOpportunityField.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState } from 'react'; -import Image from 'next/image'; -import { Plus, X } from 'lucide-react'; -import { useFormContext } from 'react-hook-form'; -import { SelectFundingOpportunityModal } from '@/components/modals/SelectFundingOpportunityModal'; -import { GRANT_IMAGE_FALLBACK_GRADIENT } from '@/types/grant'; -import { formatCompactAmount } from '@/utils/currency'; -import type { PublishingFormData } from '../schema'; -import { useSelectedGrant } from '../useSelectedGrant'; - -interface FundingOpportunityFieldProps { - readonly noteId: number; -} - -/** - * The Request for Proposal this proposal answers: the chosen one as a card, - * or a prompt to pick one. Renders nothing once the proposal is published, - * because the answer cannot change after that. - */ -export function FundingOpportunityField({ noteId }: FundingOpportunityFieldProps) { - const { watch } = useFormContext(); - const workId = watch('workId'); - const { selectedGrant, isSaving, save } = useSelectedGrant(noteId); - const [isModalOpen, setIsModalOpen] = useState(false); - - if (workId) return null; - - return ( - <> -
-

- Request for Proposal (Optional) -

- {selectedGrant ? ( -
-
- {selectedGrant.imageUrl ? ( - {selectedGrant.shortTitle} - ) : ( -
- )} -
-
-
- {selectedGrant.organization || 'RFP'} -
-
- {selectedGrant.shortTitle} -
-
- {formatCompactAmount(selectedGrant.fundingAmount)} Funding -
-
- -
- ) : ( - - )} -
- - setIsModalOpen(false)} - onSelect={(grant) => void save(grant)} - /> - - ); -} diff --git a/components/Notebook/PublishingForm/components/FundingSection.tsx b/components/Notebook/PublishingForm/components/FundingSection.tsx deleted file mode 100644 index 2e6a538d9..000000000 --- a/components/Notebook/PublishingForm/components/FundingSection.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { useFormContext } from 'react-hook-form'; -import { Upload, Image as ImageIcon, Gift } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/form/Input'; -import { Switch } from '@/components/ui/Switch'; -import Image from 'next/image'; -import { cn } from '@/utils/styles'; -import { useRef, useState, useEffect } from 'react'; -import type { Note } from '@/types/note'; -import { NonprofitSearchSection } from '@/components/Nonprofit'; -import { useNonprofitByFundraiseId } from '@/hooks/useNonprofitByFundraiseId'; -import { useNonprofitSearch } from '@/hooks/useNonprofitSearch'; -import { FundingGoalField } from './FundingGoalField'; -import { FundingOpportunityField } from './FundingOpportunityField'; - -interface FundingSectionProps { - note: Note; -} - -const FEATURE_FLAG_NFT_REWARDS = false; - -export function FundingSection({ note }: Readonly) { - const { register, watch, setValue } = useFormContext(); - const rewardFunders = watch('rewardFunders'); - const budget = watch('budget'); - const nftSupply = watch('nftSupply'); - const fileInputRef = useRef(null); - const [previewUrl, setPreviewUrl] = useState(null); - const [isEditingNonprofit, setIsEditingNonprofit] = useState(false); - - const fundraise = note.post?.fundraise; - const { - nonprofit, - departmentLabName: existingNote, - isLoading: isLoadingNonprofit, - } = useNonprofitByFundraiseId(fundraise?.id); - const { results: nonprofitSearchResults, searchNonprofits } = useNonprofitSearch(); - - // If we have a fundraise and a nonprofit with EIN, search for complete data - useEffect(() => { - if (nonprofit?.ein && !isEditingNonprofit) { - searchNonprofits(nonprofit.ein); - } - }, [nonprofit, searchNonprofits, isEditingNonprofit]); - - // When we have both the nonprofit from the fundraise and search results, merge the data - // and set it in the form - useEffect(() => { - if ( - nonprofit && - nonprofitSearchResults && - nonprofitSearchResults.length > 0 && - !isEditingNonprofit - ) { - const matchedNonprofit = nonprofitSearchResults.find( - (result) => result.ein === nonprofit.ein - ); - - if (matchedNonprofit) { - const mergedNonprofit = { - ...matchedNonprofit, - id: nonprofit.id, - endaomentOrgId: nonprofit.endaomentOrgId || matchedNonprofit.endaomentOrgId, - baseWalletAddress: nonprofit.baseWalletAddress || matchedNonprofit.baseWalletAddress, - }; - - setValue('selectedNonprofit', mergedNonprofit); - setValue('departmentLabName', existingNote); - } else { - setValue('selectedNonprofit', nonprofit); - setValue('departmentLabName', existingNote); - } - } else if (nonprofit && !isEditingNonprofit) { - setValue('selectedNonprofit', nonprofit); - setValue('departmentLabName', existingNote); - } - }, [nonprofit, nonprofitSearchResults, existingNote, setValue, isEditingNonprofit]); - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - setValue('nftArt', file); - const url = URL.createObjectURL(file); - setPreviewUrl(url); - } - }; - - const formatNumberWithCommas = (value: string) => { - const numericValue = value.replaceAll(/\D/g, ''); - return numericValue ? Number.parseInt(numericValue).toLocaleString() : ''; - }; - - const calculateMinDonation = () => { - const funding = Number.parseFloat(budget?.replaceAll(/[^0-9.]/g, '') || '0'); - const supply = Number.parseInt(nftSupply?.replaceAll(/\D/g, '') || '0'); - if (!funding || !supply) return '0.00'; - return (funding / supply).toFixed(2); - }; - - return ( -
- - - {fundraise ? ( - <> -
- {isLoadingNonprofit && ( -

Loading nonprofit information...

- )} - {!isLoadingNonprofit && (isEditingNonprofit || !nonprofit) && ( - - )} - {!isLoadingNonprofit && !isEditingNonprofit && nonprofit && ( - setIsEditingNonprofit(true)} - /> - )} -
- - ) : ( - <> - - -
- -
- - )} - - {FEATURE_FLAG_NFT_REWARDS && ( - <> -
-
-
- -

- Reward Funders -

-
- setValue('rewardFunders', checked)} - /> -
-

- Reward your funders with a memento of your research as an NFT. -

-
- {rewardFunders && ( - <> - NFT Art Upload Section -
- NFT Art - - -
- NFT Supply Section -
- { - const formatted = formatNumberWithCommas(e.target.value); - setValue('nftSupply', formatted, { shouldValidate: true }); - }} - /> -
- Donation Rewards Section -
-
- - Donation Rewards -
-
- 0 - ? `$${calculateMinDonation()} per NFT` - : '-' - } - className="w-full pl-3 pr-12 py-2 text-sm text-gray-900 bg-transparent border-0 cursor-default focus:outline-none focus:ring-0" - /> -
-

- {Number.parseFloat(calculateMinDonation()) > 0 - ? `Funders must donate at least $${calculateMinDonation()} USD to receive one research memento NFT` - : 'Set a funding goal and NFT supply to calculate the minimum donation per NFT'} -

-
- - )} - - )} -
- ); -} diff --git a/components/Notebook/PublishingForm/components/GrantApplicationVisibilitySection.tsx b/components/Notebook/PublishingForm/components/GrantApplicationVisibilitySection.tsx index 215364a9d..ab7be66b3 100644 --- a/components/Notebook/PublishingForm/components/GrantApplicationVisibilitySection.tsx +++ b/components/Notebook/PublishingForm/components/GrantApplicationVisibilitySection.tsx @@ -1,7 +1,3 @@ -import { useFormContext } from 'react-hook-form'; -import { Eye } from 'lucide-react'; -import { RadioGroup } from '@/components/ui/form/RadioGroup'; -import { SectionHeader } from './SectionHeader'; import { CommunityMatchTooltip } from '@/components/tooltips/CommunityMatchTooltip'; const CommunityMatchBadge = () => ( @@ -34,23 +30,3 @@ export const GRANT_APPLICATION_VISIBILITY_OPTIONS = [ description: 'Only yourself and peer-reviewers can view private proposals.', }, ]; - -export function GrantApplicationVisibilitySection() { - const { watch, setValue } = useFormContext(); - const value = watch('applicationVisibility') ?? 'OPTIONAL'; - - return ( -
- Application Visibility -

{GRANT_APPLICATION_VISIBILITY_HELP}

-
- setValue('applicationVisibility', next, { shouldValidate: true })} - size="sm" - /> -
-
- ); -} diff --git a/components/Notebook/PublishingForm/components/GrantDescriptionSection.tsx b/components/Notebook/PublishingForm/components/GrantDescriptionSection.tsx deleted file mode 100644 index 6f8a5ad21..000000000 --- a/components/Notebook/PublishingForm/components/GrantDescriptionSection.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useFormContext } from 'react-hook-form'; -import { FileText } from 'lucide-react'; -import { Textarea } from '@/components/ui/form/Textarea'; -import { SectionHeader } from './SectionHeader'; -import { cn } from '@/utils/styles'; -import type { SectionProps } from './SectionProps'; - -export function GrantDescriptionSection({ className }: SectionProps) { - const { - register, - formState: { errors }, - } = useFormContext(); - - return ( -
- Short Description -
-