RND-5227: Site section tabs (#2530)

This commit is contained in:
Brett Jephson
2024-10-18 09:47:53 +01:00
committed by GitHub
parent 065627060b
commit 2fa08519b6
10 changed files with 246 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': minor
---
Add navigation tabs for sections
@@ -39,6 +39,7 @@ export default async function ContentLayout(props: { children: React.ReactNode }
spaces,
ancestors,
scripts,
sections,
} = await fetchContentData();
ReactDOM.preconnect(api().endpoint);
@@ -66,6 +67,7 @@ export default async function ContentLayout(props: { children: React.ReactNode }
contentTarget={contentTarget}
site={site}
spaces={spaces}
sections={sections}
customization={customization}
pages={pages}
ancestors={ancestors}
+54 -18
View File
@@ -1,4 +1,5 @@
import { RevisionPage, Space } from '@gitbook/api';
import { RevisionPage, SiteSection, SiteSpace, Space } from '@gitbook/api';
import { assert } from 'ts-essentials';
import {
getRevisionPageByPath,
@@ -6,8 +7,8 @@ import {
ContentTarget,
getSiteData,
getSite,
getSiteSpaces,
getCurrentSiteCustomization,
getSiteStructure,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
import { getSiteContentPointer } from '@/lib/pointer';
@@ -20,6 +21,8 @@ export interface PageIdParams {
pageId: string;
}
type SectionsList = { list: SiteSection[]; section: SiteSection };
/**
* Fetch all the data needed to render the content layout.
*/
@@ -37,15 +40,25 @@ export async function fetchContentData() {
]);
const site = siteStructure.site;
const spaces = siteStructure.spaces;
const siteSections =
content.siteSectionId && siteStructure.sections
? parseSiteSectionsList(content.siteSectionId, siteStructure.sections)
: null;
const spaces =
siteStructure.spaces ??
(siteSections ? parseSpacesFromSiteSpaces(siteSections.section.siteSpaces) : []);
// we grab the space attached to the parent as it contains overriden customizations
const spaceRelativeToParent = spaces.find((space) => space.id === content.spaceId);
const spaceRelativeToParent = spaces?.find((space) => space.id === content.spaceId);
return {
content,
contentTarget,
space: spaceRelativeToParent ?? space,
pages,
sections: siteSections,
site,
spaces,
customization,
@@ -54,6 +67,16 @@ export async function fetchContentData() {
};
}
function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) {
const section = sections.find((section) => section.id === siteSectionId);
assert(sectionIsDefined(section), 'A section must be defined when there are multiple sections');
return { list: sections, section } satisfies SectionsList;
}
function sectionIsDefined(section?: SiteSection): section is NonNullable<SiteSection> {
return typeof section !== 'undefined' && section !== null;
}
/**
* Fetch all the data needed to render the content.
* Optimized to fetch in parallel as much as possible.
@@ -129,7 +152,7 @@ async function resolvePage(
/**
* Fetch the structure of an organization site.
* This includes the site and its spaces.
* This includes the site and its sections or spaces.
*/
async function fetchSiteStructure(args: {
organizationId: string;
@@ -137,12 +160,35 @@ async function fetchSiteStructure(args: {
siteShareKey: string | undefined;
}) {
const { organizationId, siteId, siteShareKey } = args;
const [orgSite, siteSpaces, siteParentCustomizations] = await Promise.all([
const [orgSite, siteStructure, siteParentCustomizations] = await Promise.all([
getSite(organizationId, siteId),
getSiteSpaces({ organizationId, siteId, siteShareKey }),
getSiteStructure({ organizationId, siteId, siteShareKey }),
getCurrentSiteCustomization({ organizationId, siteId, siteSpaceId: undefined }),
]);
const siteSections =
siteStructure.type === 'sections' && siteStructure.structure
? siteStructure.structure
: null;
const siteSpaces =
siteStructure.type === 'siteSpaces' && siteStructure.structure
? parseSpacesFromSiteSpaces(siteStructure.structure)
: null;
// override the title with the customization title
const site = {
...orgSite,
...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}),
};
return {
site,
spaces: siteSpaces,
sections: siteSections,
};
}
function parseSpacesFromSiteSpaces(siteSpaces: SiteSpace[]) {
const spaces: Record<string, Space> = {};
siteSpaces.forEach((siteSpace) => {
spaces[siteSpace.space.id] = {
@@ -154,17 +200,7 @@ async function fetchSiteStructure(args: {
},
};
});
// override the title with the customization title
const site = {
...orgSite,
...(siteParentCustomizations?.title ? { title: siteParentCustomizations.title } : {}),
};
return {
site,
spaces: Object.values(spaces),
};
return Object.values(spaces);
}
/**
@@ -1,10 +1,4 @@
import {
Collection,
CustomizationSettings,
Site,
SiteCustomizationSettings,
Space,
} from '@gitbook/api';
import { CustomizationSettings, Site, SiteCustomizationSettings, Space } from '@gitbook/api';
import { CustomizationHeaderPreset } from '@gitbook/api';
import { Suspense } from 'react';
@@ -0,0 +1,134 @@
'use client';
import { SiteSection } from '@gitbook/api';
import { Icon } from '@gitbook/icons';
import React from 'react';
import { tcls } from '@/lib/tailwind';
import { Button, Link } from '../primitives';
/**
* A set of tabs representing site sections for multi-section sites
*/
export function SiteSectionTabs(props: { sections: SiteSection[]; section: SiteSection }) {
const { sections, section: currentSection } = props;
const tabs = sections.map((section) => ({
id: section.id,
label: section.title,
path: section.urls.published ?? '',
}));
const currentTabRef = React.useRef<HTMLAnchorElement>(null);
const navRef = React.useRef<HTMLDivElement>(null);
const [currentIndex, setCurrentIndex] = React.useState(
sections.findIndex((section) => section.id === currentSection?.id) || 0,
);
const [tabDimensions, setTabDimensions] = React.useState<{
left: number;
width: number;
} | null>(null);
React.useEffect(() => {
if (currentTabRef.current && navRef.current) {
const rect = currentTabRef.current.getBoundingClientRect();
const navRect = navRef.current.getBoundingClientRect();
setTabDimensions({ left: rect.left - navRect.left, width: rect.width });
}
}, [currentIndex]);
React.useLayoutEffect(() => {
function onResize() {
if (currentTabRef.current && navRef.current) {
const rect = currentTabRef.current.getBoundingClientRect();
const navRect = navRef.current.getBoundingClientRect();
setTabDimensions({ left: rect.left - navRect.left, width: rect.width });
}
}
window.addEventListener('resize', onResize);
() => window.removeEventListener('resize', onResize);
}, []);
const scale = (tabDimensions?.width ?? 0) * 0.01;
const startPos = `${tabDimensions?.left ?? 0}px`;
const hasMoreSections = false; /** TODO: determine whether we need to show the more button */
return tabs.length > 0 ? (
<nav
ref={navRef}
className="sm:mx-0 md:-mx-2 flex flex-nowrap items-center my-4 max-w-screen"
style={
{
'--tab-scale': `${scale}`,
'--tab-start': `${startPos}`,
} as React.CSSProperties
}
>
<div
className={tcls(
'relative flex gap-2',
/* add a pseudo element for active tab indicator */
"after:block after:content-[''] after:origin-left after:absolute after:bottom-0 after:left-0 after:scale-x-[--tab-scale] after:transition-transform after:translate-x-[var(--tab-start)] after:h-0.5 after:w-[100px] after:bg-primary dark:after:bg-primary-500",
)}
role="tablist"
>
{tabs.map((tab, index) => (
<Tab
active={currentIndex === index}
key={index + tab.path}
label={tab.label}
href={tab.path}
onClick={() => {
setCurrentIndex(index);
}}
ref={currentIndex === index ? currentTabRef : null}
/>
))}
</div>
{hasMoreSections ? <MoreSectionsButton /> : null}
</nav>
) : null;
}
/**
* The tab item - a link to a site section
*/
const Tab = React.forwardRef<
HTMLAnchorElement,
{ active: boolean; href: string; label: string; onClick: any }
>(function Tab(props, ref) {
const { active, href, label, onClick } = props;
return (
<div
className={tcls(
'my-0.5 px-2 py-1 rounded',
!active && 'hover:bg-dark/1 dark:hover:bg-light/2 transition-colors',
)}
>
<Link
ref={ref}
onClick={onClick}
className={tcls('inline-flex w-full truncate')}
role="tab"
href={href}
>
{label}
</Link>
</div>
);
});
/**
* Dropdown trigger for when there are too many sections to show them all
*/
function MoreSectionsButton() {
return (
<div>
<Button variant="secondary" size="small">
<Icon icon="ellipsis-h" size={12} />
</Button>
</div>
);
}
@@ -0,0 +1 @@
export * from './SiteSectionTab';
@@ -6,6 +6,7 @@ import {
RevisionPageGroup,
Site,
SiteCustomizationSettings,
SiteSection,
Space,
} from '@gitbook/api';
import React from 'react';
@@ -20,6 +21,8 @@ import { ContentTarget, SiteContentPointer } from '@/lib/api';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { SiteSectionTabs } from '../SiteSectionTabs';
/**
* Render the entire content of the space (header, table of contents, footer, and page content).
*/
@@ -28,6 +31,7 @@ export function SpaceLayout(props: {
contentTarget: ContentTarget;
space: Space;
site: Site | null;
sections: { list: SiteSection[]; section: SiteSection } | null;
spaces: Space[];
customization: CustomizationSettings | SiteCustomizationSettings;
pages: Revision['pages'];
@@ -38,6 +42,7 @@ export function SpaceLayout(props: {
space,
contentTarget,
site,
sections,
spaces,
content,
pages,
@@ -66,8 +71,12 @@ export function SpaceLayout(props: {
context={contentRefContext}
customization={customization}
/>
<div className={tcls('scroll-nojump')}>
{sections ? (
<div className={tcls(CONTAINER_STYLE)}>
<SiteSectionTabs sections={sections.list} section={sections.section} />
</div>
) : null}
<div
className={tcls(
'flex',
+34
View File
@@ -45,6 +45,10 @@ export interface SpaceContentPointer {
export interface SiteContentPointer extends SpaceContentPointer {
organizationId: string;
siteId: string;
/**
* ID of the siteSection. When rendering a multi-section site. Can be undefined.
*/
siteSectionId: string | undefined;
/**
* ID of the siteSpace can be undefined when rendering in multi-id mode (for site previews)
*/
@@ -737,6 +741,36 @@ export const getSiteSpaces = cache({
},
});
export const getSiteStructure = cache({
name: 'api.getSiteStructure',
tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }),
get: async (
args: {
organizationId: string;
siteId: string;
/** Site share key that can be used as context to resolve site space published urls */
siteShareKey: string | undefined;
},
options: CacheFunctionOptions,
) => {
const response = await api().orgs.getSiteStructure(
args.organizationId,
args.siteId,
{
...(args.siteShareKey ? { shareKey: args.siteShareKey } : {}),
},
{
...noCacheFetchOptions,
signal: options.signal,
},
);
return cacheResponse(response, {
revalidateBefore: 60 * 60,
data: response.data,
});
},
});
/**
* List the scripts to load for the site.
*/
+2
View File
@@ -11,6 +11,7 @@ export function getSiteContentPointer(): SiteContentPointer {
const siteId = headerSet.get('x-gitbook-content-site');
const organizationId = headerSet.get('x-gitbook-content-organization');
const siteSpaceId = headerSet.get('x-gitbook-content-site-space');
const siteSectionId = headerSet.get('x-gitbook-content-site-section');
const siteShareKey = headerSet.get('x-gitbook-content-site-share-key');
if (!spaceId || !siteId || !organizationId) {
@@ -22,6 +23,7 @@ export function getSiteContentPointer(): SiteContentPointer {
const pointer: SiteContentPointer = {
siteId,
spaceId,
siteSectionId: siteSectionId ?? undefined,
siteSpaceId: siteSpaceId ?? undefined,
siteShareKey: siteShareKey ?? undefined,
organizationId,
+3
View File
@@ -200,6 +200,9 @@ export async function middleware(request: NextRequest) {
if ('site' in resolved) {
headers.set('x-gitbook-content-organization', resolved.organization);
headers.set('x-gitbook-content-site', resolved.site);
if (resolved.siteSection) {
headers.set('x-gitbook-content-site-section', resolved.siteSection);
}
if (resolved.siteSpace) {
headers.set('x-gitbook-content-site-space', resolved.siteSpace);
}