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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 31 additions & 33 deletions app/layouts/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,18 @@ import { Logo } from '@/components/ui/Logo';
import Link from 'next/link';
import { Icon } from '@/components/ui/icons';
import { FundingPowerRailButton } from '@/components/Funding/FundingPowerRailButton';
import { SidebarDocuments } from './components/SidebarDocuments';

interface LeftSidebarProps {
forceMinimize?: boolean;
/**
* Takes the place of the nav items, the footer links and the funding power
* button: a page whose own lists belong in this column. Hidden while the
* column is the icon rail, where the page shows them some other way; pass
* null to keep the area empty.
* Runs once a document or a new draft is picked: on a phone the column is a
* menu, and a pick that changes the workspace in place must still close it.
*/
content?: React.ReactNode;
onNavigate?: () => void;
}

export const LeftSidebar: React.FC<LeftSidebarProps> = ({ forceMinimize = false, content }) => {
export const LeftSidebar: React.FC<LeftSidebarProps> = ({ forceMinimize = false, onNavigate }) => {
const pathname = usePathname();

const handleUnimplementedFeature = (featureName: string) => {
Expand All @@ -47,7 +46,6 @@ export const LeftSidebar: React.FC<LeftSidebarProps> = ({ forceMinimize = false,

// Create minimized classes based on either responsive design or forced minimization
const minimizeClass = forceMinimize ? 'minimized-sidebar' : '';
const hasContent = content !== undefined;

return (
<div className={`h-full flex flex-col z-50 bg-white overflow-hidden ${minimizeClass}`}>
Expand All @@ -67,36 +65,36 @@ export const LeftSidebar: React.FC<LeftSidebarProps> = ({ forceMinimize = false,
<div
className={`mt-6 px-3 ${forceMinimize ? '!flex !justify-center !px-2' : 'tablet:max-sidebar-compact:!flex tablet:max-sidebar-compact:!justify-center tablet:max-sidebar-compact:!px-2'}`}
>
<PublishMenu forceMinimize={forceMinimize} />
<PublishMenu forceMinimize={forceMinimize} onItemSelected={onNavigate} />
</div>

{hasContent ? (
<div
className={`mt-3 min-h-0 flex-1 ${forceMinimize ? '!hidden' : 'tablet:max-sidebar-compact:!hidden'}`}
>
{content}
</div>
) : (
<>
<Navigation
currentPath={pathname || ''}
onUnimplementedFeature={handleUnimplementedFeature}
forceMinimize={forceMinimize}
/>
{/* The nav and the user's documents scroll together, so a long list
never squeezes the nav. The documents hide while the column is the
icon rail; the workspace has its own way to them there. */}
<div className="min-h-0 flex-1 overflow-y-auto">
<Navigation
currentPath={pathname || ''}
onUnimplementedFeature={handleUnimplementedFeature}
forceMinimize={forceMinimize}
inScrollArea
/>
<SidebarDocuments
onNavigate={onNavigate}
className={forceMinimize ? '!hidden' : 'tablet:max-sidebar-compact:!hidden'}
/>
</div>

{/* Navigation above is flex-1, so this sits at the bottom of the column.
Only covers 768px to the right sidebar's breakpoint: below that the
bar is docked over the mobile bottom nav, and above it the funding
power card is in the right sidebar. */}
<div className={`px-2 pb-3 ${forceMinimize ? '!block' : 'hidden tablet:max-lg:!block'}`}>
<FundingPowerRailButton />
</div>
{/* The scroll area above is flex-1, so this sits at the bottom of the column.
Only covers 768px to the right sidebar's breakpoint: below that the
bar is docked over the mobile bottom nav, and above it the funding
power card is in the right sidebar. */}
<div className={`px-2 pb-3 ${forceMinimize ? '!block' : 'hidden tablet:max-lg:!block'}`}>
<FundingPowerRailButton />
</div>

<div className={forceMinimize ? '!hidden' : 'tablet:max-sidebar-compact:!hidden'}>
<FooterLinks />
</div>
</>
)}
<div className={forceMinimize ? '!hidden' : 'tablet:max-sidebar-compact:!hidden'}>
<FooterLinks />
</div>
</div>
);
};
6 changes: 5 additions & 1 deletion app/layouts/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ interface NavigationProps {
currentPath: string;
onUnimplementedFeature: (featureName: string) => void;
forceMinimize?: boolean;
/** A parent scrolls the nav along with what follows it, so it neither grows nor scrolls itself. */
inScrollArea?: boolean;
}

const navIconMap: Record<NavIconKey, NavIcon> = {
Expand All @@ -57,6 +59,7 @@ export const Navigation: React.FC<NavigationProps> = ({
currentPath,
onUnimplementedFeature,
forceMinimize = false,
inScrollArea = false,
}) => {
const navigationItems: NavigationItem[] = [
{
Expand Down Expand Up @@ -210,7 +213,8 @@ export const Navigation: React.FC<NavigationProps> = ({
<nav
aria-label="Primary navigation"
className={cn(
'flex-1 overflow-y-auto px-3 pt-6',
'px-3 pt-6',
!inScrollArea && 'flex-1 overflow-y-auto',
forceMinimize ? '!px-2' : 'tablet:max-sidebar-compact:!px-2'
)}
>
Expand Down
8 changes: 1 addition & 7 deletions app/layouts/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,6 @@ interface PageLayoutProps {
* of empty space beside the content.
*/
wideRow?: boolean;
/**
* Shown in the left column in place of the nav items: a page whose own
* lists live there. See `LeftSidebar`'s `content`.
*/
leftSidebarContent?: ReactNode;
/**
* The page is an app surface, not a document: it fills the area beside the
* left column and under the top bar edge to edge and manages its own
Expand All @@ -76,7 +71,6 @@ function PageLayoutInner({
rightSidebarFill = false,
contentWidth = 'default',
wideRow = false,
leftSidebarContent,
fullBleed = false,
}: PageLayoutProps) {
const isNarrow = contentWidth === 'narrow';
Expand Down Expand Up @@ -115,7 +109,7 @@ function PageLayoutInner({

<MobileOverlay show={showOverlay} visible={overlayVisible} onClose={closeLeftSidebar} />

<LeftSidebarContainer isOpen={isLeftSidebarOpen} content={leftSidebarContent} />
<LeftSidebarContainer isOpen={isLeftSidebarOpen} onClose={closeLeftSidebar} />

{fullBleed ? (
// Offset from the fixed top bar exactly as the scrollport below is,
Expand Down
201 changes: 31 additions & 170 deletions app/layouts/PublishMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,15 @@
'use client';

import { ChevronRight, Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu';
import { FUNDING_KIND_ICON } from '@/components/Funding/fundingKind';
import { Plus } from 'lucide-react';
import { BaseMenu } from '@/components/ui/form/BaseMenu';
import { useAuthenticatedAction } from '@/contexts/AuthModalContext';
import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer';
import {
OpenFundingOpportunityModal,
type FundingOpportunityCreationMethod,
} from '@/components/Funding/OpenFundingOpportunityModal';
import {
OpenProposalModal,
type ProposalCreationMethod,
} from '@/components/Funding/OpenProposalModal';
import type { FundingIntent } from '@/components/Funding/fundingDirection';
FUNDING_DRAFT_OPTIONS,
FundingDraftMenuItems,
FundingDraftOptionContent,
type FundingDraftOption,
} from '@/components/Funding/fundingDraftOptions';
import { useFundingDrafting } from '@/components/Funding/useFundingDrafting';
import { useScreenSize } from '@/hooks/useScreenSize';
import { useState } from 'react';
Expand All @@ -26,111 +20,22 @@ interface PublishMenuProps {
onItemSelected?: () => void;
}

interface PublishMenuItem {
readonly id: string;
readonly title: string;
readonly description: string;
readonly icon: React.ReactNode;
/** Which side of the money the item starts: an RFP funds, a proposal needs funding. */
readonly intent: FundingIntent;
}

const PUBLISH_MENU_SECTIONS: readonly {
readonly title: string;
readonly items: readonly PublishMenuItem[];
}[] = [
{
title: 'Publish on ResearchHub',
items: [
{
id: 'give-funding',
title: 'Request for Proposal',
description: 'Fund specific research you care about',
icon: (
<FontAwesomeIcon
icon={FUNDING_KIND_ICON.rfp}
className="h-[18px] w-[18px] text-gray-700"
/>
),
intent: 'fund',
},
{
id: 'request-funding',
title: 'Proposal',
description: 'Raise money for your research',
icon: (
<FontAwesomeIcon
icon={FUNDING_KIND_ICON.proposal}
className="h-[18px] w-[18px] text-gray-700"
/>
),
intent: 'need_funding',
},
],
},
];

interface MenuItemContentProps {
icon: React.ReactNode;
title: string;
description: string;
}

const MenuItemContent: React.FC<MenuItemContentProps> = ({ icon, title, description }) => {
return (
<div className="relative flex w-full items-center gap-3 pr-6">
<div className="flex-shrink-0">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gray-100 transition-colors duration-150 group-hover:bg-gray-50">
{icon}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold tracking-[0.01em] text-gray-900">{title}</div>
<div className="text-xs text-gray-600">{description}</div>
</div>
<ChevronRight className="absolute right-0 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-900 transition-opacity duration-200 opacity-0 group-hover:opacity-100" />
</div>
);
};

/**
* The sidebar's Publish button: a Request for Proposal or a Proposal. For a
* user the workspace admits, either leads straight to a new conversation
* there for that side of the money; everyone else gets the notebook's
* opening modal and editor, as before.
* 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.
*/
export const PublishMenu: React.FC<PublishMenuProps> = ({
forceMinimize = false,
onItemSelected,
}) => {
const router = useRouter();
const { executeAuthenticatedAction } = useAuthenticatedAction();
const { inWorkspace, startNew } = useFundingDrafting();
const { startNew } = useFundingDrafting();
const { smAndDown } = useScreenSize();
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);
const [isFundingOpportunityModalOpen, setIsFundingOpportunityModalOpen] = useState(false);
const [isProposalModalOpen, setIsProposalModalOpen] = useState(false);

const handleConfirmCreateProposal = (method: ProposalCreationMethod) => {
setIsProposalModalOpen(false);
router.push(`/notebook?newFunding=true&proposalSource=${method}`);
};

const handleConfirmOpenGrant = (method: FundingOpportunityCreationMethod) => {
setIsFundingOpportunityModalOpen(false);
router.push(`/notebook?newGrant=true&grantSource=${method}`);
};

const handleMenuItemClick = (item: PublishMenuItem) => {
executeAuthenticatedAction(() => {
if (inWorkspace) {
startNew(item.intent);
} else if (item.intent === 'fund') {
setIsFundingOpportunityModalOpen(true);
} else {
setIsProposalModalOpen(true);
}
});
const handleMenuItemClick = (item: FundingDraftOption) => {
executeAuthenticatedAction(() => startNew(item.intent));

// Close mobile drawer after action
if (smAndDown) {
Expand Down Expand Up @@ -174,59 +79,27 @@ export const PublishMenu: React.FC<PublishMenuProps> = ({
</button>
);

const menuContent = (
<div className="space-y-3">
{PUBLISH_MENU_SECTIONS.map((section) => (
<div key={section.title}>
<div className="space-y-1">
{section.items.map((item) => (
<BaseMenuItem
key={item.id}
onClick={() => handleMenuItemClick(item)}
className="group w-full cursor-pointer px-2 py-2 rounded-lg transition-colors duration-150 hover:bg-gray-100 focus:bg-gray-100"
>
<MenuItemContent
icon={item.icon}
title={item.title}
description={item.description}
/>
</BaseMenuItem>
))}
</div>
</div>
))}
</div>
);
const menuContent = <FundingDraftMenuItems onSelect={handleMenuItemClick} />;

// Mobile drawer content
const mobileDrawerContent = (
<div className="space-y-4">
{PUBLISH_MENU_SECTIONS.map((section) => (
<div key={section.title}>
<div className="space-y-2">
{section.items.map((item) => (
<div
key={item.id}
onClick={() => handleMenuItemClick(item)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleMenuItemClick(item);
}
}}
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}`}
>
<MenuItemContent
icon={item.icon}
title={item.title}
description={item.description}
/>
</div>
))}
</div>
<div className="space-y-2">
{FUNDING_DRAFT_OPTIONS.map((item) => (
<div
key={item.id}
onClick={() => handleMenuItemClick(item)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleMenuItemClick(item);
}
}}
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}`}
>
<FundingDraftOptionContent option={item} />
</div>
))}
</div>
Expand Down Expand Up @@ -291,18 +164,6 @@ export const PublishMenu: React.FC<PublishMenuProps> = ({
</BaseMenu>
</>
)}

<OpenFundingOpportunityModal
isOpen={isFundingOpportunityModalOpen}
onClose={() => setIsFundingOpportunityModalOpen(false)}
onConfirm={handleConfirmOpenGrant}
/>

<OpenProposalModal
isOpen={isProposalModalOpen}
onClose={() => setIsProposalModalOpen(false)}
onConfirm={handleConfirmCreateProposal}
/>
</div>
);
};
Loading
Loading