mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
RND-5227: Site section tabs (#2530)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user