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
22 changes: 22 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,28 @@ body {
}
}

/* The workspace page's lists and panes arrive by opacity alone, while the
logo, the Publish button and the top bar around them hold still. Not
`.animate-in`, which also slides. */
.workspace-fade-in {
animation: workspaceFadeIn 0.2s ease-out;
}

@keyframes workspaceFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@media (prefers-reduced-motion: reduce) {
.workspace-fade-in {
animation: none;
}
}

/* Hide scrollbar for Chrome, Safari and Opera */
.scrollbar-hide::-webkit-scrollbar {
display: none;
Expand Down
50 changes: 34 additions & 16 deletions app/layouts/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@ import { FundingPowerRailButton } from '@/components/Funding/FundingPowerRailBut

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.
*/
content?: React.ReactNode;
}

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

const handleUnimplementedFeature = (featureName: string) => {
Expand All @@ -40,6 +47,7 @@ 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 @@ -62,23 +70,33 @@ export const LeftSidebar: React.FC<LeftSidebarProps> = ({ forceMinimize = false
<PublishMenu forceMinimize={forceMinimize} />
</div>

<Navigation
currentPath={pathname || ''}
onUnimplementedFeature={handleUnimplementedFeature}
forceMinimize={forceMinimize}
/>
{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}
/>

{/* 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>
{/* 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>

<div className={forceMinimize ? '!hidden' : 'tablet:max-sidebar-compact:!hidden'}>
<FooterLinks />
</div>
<div className={forceMinimize ? '!hidden' : 'tablet:max-sidebar-compact:!hidden'}>
<FooterLinks />
</div>
</>
)}
</div>
);
};
130 changes: 82 additions & 48 deletions app/layouts/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ 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
* scrolling. No page container, padding, right sidebar or mobile bottom nav.
*/
fullBleed?: boolean;
}

function PageLayoutInner({
Expand All @@ -65,6 +76,8 @@ function PageLayoutInner({
rightSidebarFill = false,
contentWidth = 'default',
wideRow = false,
leftSidebarContent,
fullBleed = false,
}: PageLayoutProps) {
const isNarrow = contentWidth === 'narrow';
const pathname = usePathname() || '';
Expand Down Expand Up @@ -102,65 +115,86 @@ function PageLayoutInner({

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

<LeftSidebarContainer isOpen={isLeftSidebarOpen} />

{/* Scrollable content area.
Mobile: top padding clears the fixed top bar (needed so content
can scroll under the hide-on-scroll bar).
Tablet+: top margin instead, so the scrollport starts below the bar.
When the EndowmentPromoBanner is visible above the TopBar on mobile
we add extra top padding to clear the banner + topbar stack.
The banner itself is hidden at >= 768px (tablet:!hidden) so the
offset is reset by the inner media query below.
Bottom padding on mobile clears the fixed MobileBottomNav. */}
<div
ref={scrollContainerRef}
className={cn(
'flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain relative transition-all duration-150',
// The funding power bar only docks above the nav on the home tabs
// (see MobileBottomNav), so only those pages reserve room for it.
isHomeTab
? 'page-layout-with-mobile-bottom-nav-and-funding-bar'
: 'page-layout-with-mobile-bottom-nav',
'pt-[var(--top-bar-height)] mt-0',
'tablet:!pt-0 tablet:!mt-[var(--top-bar-height)]',
isPromoBannerVisible && 'page-layout-with-promo-banner'
)}
>
{topBanner && <div className="w-full">{topBanner}</div>}
<LeftSidebarContainer isOpen={isLeftSidebarOpen} content={leftSidebarContent} />

{fullBleed ? (
// Offset from the fixed top bar exactly as the scrollport below is,
// but nothing here scrolls: the page lays out its own panes in the
// height that is left, so the top bar never hides on a phone.
<div
ref={scrollContainerRef}
className={cn(
'flex mx-auto w-full transition-[max-width] duration-200 ease-out',
wideRow ? 'max-w-none' : isNarrow ? 'max-w-[1012px]' : 'max-w-[1180px]'
'relative flex min-w-0 flex-1 flex-col overflow-hidden',
'pt-[var(--top-bar-height)] mt-0',
'tablet:!pt-0 tablet:!mt-[var(--top-bar-height)]',
isPromoBannerVisible && 'page-layout-with-promo-banner'
)}
>
<main
{children}
</div>
) : (
/* Scrollable content area.
Mobile: top padding clears the fixed top bar (needed so content
can scroll under the hide-on-scroll bar).
Tablet+: top margin instead, so the scrollport starts below the bar.
When the EndowmentPromoBanner is visible above the TopBar on mobile
we add extra top padding to clear the banner + topbar stack.
The banner itself is hidden at >= 768px (tablet:!hidden) so the
offset is reset by the inner media query below.
Bottom padding on mobile clears the fixed MobileBottomNav. */
<div
ref={scrollContainerRef}
className={cn(
'flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain relative transition-all duration-150',
// The funding power bar only docks above the nav on the home tabs
// (see MobileBottomNav), so only those pages reserve room for it.
isHomeTab
? 'page-layout-with-mobile-bottom-nav-and-funding-bar'
: 'page-layout-with-mobile-bottom-nav',
'pt-[var(--top-bar-height)] mt-0',
'tablet:!pt-0 tablet:!mt-[var(--top-bar-height)]',
isPromoBannerVisible && 'page-layout-with-promo-banner'
)}
>
{topBanner && <div className="w-full">{topBanner}</div>}

<div
className={cn(
'flex-1 min-w-0 px-4 tablet:!px-8 pb-4',
topBanner ? 'py-3 sm:py-6' : 'py-6'
'flex mx-auto w-full transition-[max-width] duration-200 ease-out',
wideRow ? 'max-w-none' : isNarrow ? 'max-w-[1012px]' : 'max-w-[1180px]'
)}
>
<div
className={cn('w-full max-w-full', isNarrow && 'tablet:!max-w-[860px]', className)}
<main
className={cn(
'flex-1 min-w-0 px-4 tablet:!px-8 pb-4',
topBanner ? 'py-3 sm:py-6' : 'py-6'
)}
>
{children}
</div>
</main>
<div
className={cn(
'w-full max-w-full',
isNarrow && 'tablet:!max-w-[860px]',
className
)}
>
{children}
</div>
</main>

{rightSidebar && (
<RightSidebarContainer
rightSidebar={rightSidebar}
topOffset={rightSidebarTopOffset}
contentClassName={sidebarContentClassName}
aboveSidebar={rightSidebarAbove}
fill={rightSidebarFill}
/>
)}
</div>
{rightSidebar && (
<RightSidebarContainer
rightSidebar={rightSidebar}
topOffset={rightSidebarTopOffset}
contentClassName={sidebarContentClassName}
aboveSidebar={rightSidebarAbove}
fill={rightSidebarFill}
/>
)}
</div>

<MobileBottomNav />
</div>
<MobileBottomNav />
</div>
)}
</div>
</ScrollContainerProvider>
);
Expand Down
14 changes: 10 additions & 4 deletions app/layouts/PublishMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { useState } from 'react';

interface PublishMenuProps {
forceMinimize?: boolean;
/** Runs once an item is picked, for a host that should then get out of the way. */
onItemSelected?: () => void;
}

interface PublishMenuItem {
Expand Down Expand Up @@ -93,11 +95,14 @@ const MenuItemContent: React.FC<MenuItemContentProps> = ({ icon, title, descript

/**
* The sidebar's Publish button: a Request for Proposal or a Proposal. For a
* user the workspace admits, either opens it straight onto a conversation
* for that side of the money; everyone else gets the notebook's opening
* modal and editor, as before.
* 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.
*/
export const PublishMenu: React.FC<PublishMenuProps> = ({ forceMinimize = false }) => {
export const PublishMenu: React.FC<PublishMenuProps> = ({
forceMinimize = false,
onItemSelected,
}) => {
const router = useRouter();
const { executeAuthenticatedAction } = useAuthenticatedAction();
const { inWorkspace, startNew } = useFundingDrafting();
Expand Down Expand Up @@ -131,6 +136,7 @@ export const PublishMenu: React.FC<PublishMenuProps> = ({ forceMinimize = false
if (smAndDown) {
setIsMobileDrawerOpen(false);
}
onItemSelected?.();
};

// Regular trigger for standard mode
Expand Down
6 changes: 4 additions & 2 deletions app/layouts/components/LeftSidebarContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@

interface LeftSidebarContainerProps {
isOpen: boolean;
/** Replaces the nav items in the column; see `LeftSidebar`. */
content?: React.ReactNode;
}

export function LeftSidebarContainer({ isOpen }: LeftSidebarContainerProps) {
export function LeftSidebarContainer({ isOpen, content }: LeftSidebarContainerProps) {

Check warning on line 12 in app/layouts/components/LeftSidebarContainer.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaENPQKAQCYNvHc4HE_8&open=AaENPQKAQCYNvHc4HE_8&pullRequest=1141
return (
<div
className={cn(
Expand All @@ -24,7 +26,7 @@
isOpen ? '!translate-x-0' : '!-translate-x-full'
)}
>
<LeftSidebar />
<LeftSidebar content={content} />
</div>
);
}
7 changes: 7 additions & 0 deletions app/layouts/topbar/pageRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getTopicEmoji } from '@/components/Topic/TopicEmojis';
import { toTitleCase } from '@/utils/stringUtils';
import { getSourceLogo, getPreprintDisplayName } from '@/utils/preprintUtil';
import { HOME_TAB_PATHS, isHomeTabPath } from '@/hooks/useFundTabs';
import { AI_MODE_NAME } from '@/components/AIMode/copy';

export interface PageInfo {
title: string;
Expand Down Expand Up @@ -61,6 +62,12 @@ const ROUTE_RULES: RouteRule[] = [
icon: <Icon name="fund" size={24} className="text-gray-900" />,
}),
},
{
// Not a root navigation page, so the top bar gives it the back arrow:
// that arrow is the way out of the workspace.
match: (p) => p === '/workspace',
getInfo: () => ({ title: AI_MODE_NAME }),
},
{
match: (p) => p === '/notifications',
getInfo: () => ({
Expand Down
30 changes: 30 additions & 0 deletions app/workspace/WorkspaceClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use client';

import { Suspense } from 'react';
import dynamic from 'next/dynamic';
import { PageLayout } from '@/app/layouts/PageLayout';

/** The app's shell with the workspace's areas empty, until the workspace itself has loaded. */
function WorkspaceShell() {
return (
<PageLayout fullBleed leftSidebarContent={null}>
<div className="min-h-0 flex-1 bg-gray-50" />
</PageLayout>
);
}

// Client-only: the workspace sizes its panes from the window and from widths
// remembered in the browser, none of which a server render can know.
const WorkspacePage = dynamic(
() => import('@/components/AIMode/WorkspacePage').then((module) => module.WorkspacePage),
{ ssr: false, loading: () => <WorkspaceShell /> }
);

/** Behind Suspense because the workspace reads what it is open on from the URL's search params. */
export function WorkspaceClient() {
return (
<Suspense fallback={<WorkspaceShell />}>
<WorkspacePage />
</Suspense>
);
}
11 changes: 11 additions & 0 deletions app/workspace/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Metadata } from 'next';
import { WorkspaceClient } from './WorkspaceClient';

export const metadata: Metadata = {
title: 'Workspace',
robots: { index: false, follow: false },
};

export default function WorkspaceRoute() {
return <WorkspaceClient />;
}
Loading
Loading