mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 23:55:20 +00:00
Progressive loading (#79)
* Suspense boundary and progressive loading on blocks * Start refactoring to better leverage app router * Use skeleton for page layout * Improve dynamic toc * Switch page full width to be client side only * Format * Remove old PageLoading * Fix first page not marked as active * Close search when clicking search link
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { SkeletonHeading, SkeletonParagraph } from '@/components/primitives';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Placeholder when loading a page.
|
||||
*/
|
||||
export default function PageSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'relative',
|
||||
'py-8',
|
||||
'lg:px-12',
|
||||
'flex-1',
|
||||
'mr-56',
|
||||
// withDesktopTableOfContents ? null : 'xl:ml-72',
|
||||
)}
|
||||
>
|
||||
<div className={tcls('max-w-3xl', 'mx-auto')}>
|
||||
<SkeletonHeading style={tcls('mb-8')} />
|
||||
<SkeletonParagraph style={tcls('mb-4')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { PageAside } from '@/components/PageAside';
|
||||
import { PageBody, PageCover } from '@/components/PageBody';
|
||||
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { PagePathParams, fetchPageData, getPathnameParam } from '../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Fetch and render a page.
|
||||
*/
|
||||
export default async function Page(props: { params: PagePathParams }) {
|
||||
const { params } = props;
|
||||
|
||||
const { content, space, customization, pages, page, document } = await fetchPageData(params);
|
||||
const linksContext: PageHrefContext = {};
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
} else if (getPagePath(pages, page) !== getPathnameParam(params)) {
|
||||
redirect(pageHref(pages, page, linksContext));
|
||||
}
|
||||
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
const withFullPageCover = !!(
|
||||
page.cover &&
|
||||
page.layout.cover &&
|
||||
page.layout.coverSize === 'full'
|
||||
);
|
||||
const withPageFeedback = customization.feedback.enabled;
|
||||
|
||||
const contentRefContext: ContentRefContext = {
|
||||
space,
|
||||
pages,
|
||||
page,
|
||||
content,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{withFullPageCover && page.cover ? (
|
||||
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
|
||||
) : null}
|
||||
<div className={tcls('flex', 'flex-row')}>
|
||||
<PageBody
|
||||
space={space}
|
||||
customization={customization}
|
||||
context={contentRefContext}
|
||||
page={page}
|
||||
document={document}
|
||||
withDesktopTableOfContents={!!page.layout.tableOfContents}
|
||||
withAside={!!page.layout.outline}
|
||||
withPageFeedback={
|
||||
// Display the page feedback in the page footer if the aside is not visible
|
||||
withPageFeedback && !page.layout.outline
|
||||
}
|
||||
/>
|
||||
{page.layout.outline ? (
|
||||
<PageAside
|
||||
space={space}
|
||||
customization={customization}
|
||||
page={page}
|
||||
document={document}
|
||||
withHeaderOffset={withTopHeader}
|
||||
withFullPageCover={withFullPageCover}
|
||||
withPageFeedback={withPageFeedback}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
|
||||
const { customization } = await fetchPageData(params);
|
||||
return {
|
||||
colorScheme: customization.themes.toggeable
|
||||
? customization.themes.default === CustomizationThemeMode.Dark
|
||||
? 'dark light'
|
||||
: 'light dark'
|
||||
: customization.themes.default,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: PagePathParams }): Promise<Metadata> {
|
||||
const { space, page, customization } = await fetchPageData(params);
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${page.title} | ${space.title}`,
|
||||
description: page.description ?? '',
|
||||
openGraph: {
|
||||
images: [
|
||||
customization.socialPreview.url ?? absoluteHref(`~gitbook/ogimage/${page.id}`),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
+23
-41
@@ -1,25 +1,26 @@
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import { Metadata, Viewport } from 'next';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
import React from 'react';
|
||||
|
||||
import { CookiesToast } from '@/components/Cookies';
|
||||
import { SpaceContent } from '@/components/SpaceContent';
|
||||
import { SpaceLayout } from '@/components/SpaceLayout';
|
||||
import { getContentSecurityPolicyNonce } from '@/lib/csp';
|
||||
import { PageHrefContext, absoluteHref, baseUrl, pageHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { absoluteHref, baseUrl } from '@/lib/links';
|
||||
import { shouldIndexSpace } from '@/lib/seo';
|
||||
|
||||
import { PagePathParams, fetchPageData, getPathnameParam } from '../fetch';
|
||||
import { SpaceParams, fetchSpaceData } from '../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
/**
|
||||
* Fetch and render a page.
|
||||
* Layout when rendering the content.
|
||||
*/
|
||||
export default async function Page(props: { params: PagePathParams }) {
|
||||
const { params } = props;
|
||||
export default async function ContentLayout(props: {
|
||||
params: SpaceParams;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { params, children } = props;
|
||||
|
||||
const nonce = getContentSecurityPolicyNonce();
|
||||
const {
|
||||
@@ -27,34 +28,25 @@ export default async function Page(props: { params: PagePathParams }) {
|
||||
space,
|
||||
customization,
|
||||
pages,
|
||||
page,
|
||||
collection,
|
||||
collectionSpaces,
|
||||
ancestors,
|
||||
document,
|
||||
scripts,
|
||||
} = await fetchPageData(params);
|
||||
const linksContext: PageHrefContext = {};
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
} else if (getPagePath(pages, page) !== getPathnameParam(params)) {
|
||||
redirect(pageHref(pages, page, linksContext));
|
||||
}
|
||||
} = await fetchSpaceData(params);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpaceContent
|
||||
content={content}
|
||||
<SpaceLayout
|
||||
space={space}
|
||||
customization={customization}
|
||||
pages={pages}
|
||||
page={page}
|
||||
ancestors={ancestors}
|
||||
document={document}
|
||||
collection={collection}
|
||||
collectionSpaces={collectionSpaces}
|
||||
/>
|
||||
customization={customization}
|
||||
pages={pages}
|
||||
ancestors={ancestors}
|
||||
content={content}
|
||||
>
|
||||
{children}
|
||||
</SpaceLayout>
|
||||
|
||||
{scripts.map(({ script }) => (
|
||||
<Script key={script} src={script} strategy="lazyOnload" nonce={nonce} />
|
||||
@@ -69,8 +61,8 @@ export default async function Page(props: { params: PagePathParams }) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
|
||||
const { customization } = await fetchPageData(params);
|
||||
export async function generateViewport({ params }: { params: SpaceParams }): Promise<Viewport> {
|
||||
const { customization } = await fetchSpaceData(params);
|
||||
return {
|
||||
colorScheme: customization.themes.toggeable
|
||||
? customization.themes.default === CustomizationThemeMode.Dark
|
||||
@@ -80,17 +72,12 @@ export async function generateViewport({ params }: { params: PagePathParams }):
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: PagePathParams }): Promise<Metadata> {
|
||||
const { space, collection, page, customization } = await fetchPageData(params);
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: SpaceParams }): Promise<Metadata> {
|
||||
const { space, collection, customization } = await fetchSpaceData(params);
|
||||
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
|
||||
|
||||
return {
|
||||
title: `${page.title} | ${space.title}`,
|
||||
description: page.description ?? '',
|
||||
title: `${space.title}`,
|
||||
generator: 'GitBook',
|
||||
// We pass `metadataBase` to avoid warnings from Next, but we still use absolute URLs
|
||||
// as metadataBase doesn't seem to work well on next-on-cloudflare.
|
||||
@@ -113,11 +100,6 @@ export async function generateMetadata({ params }: { params: PagePathParams }):
|
||||
},
|
||||
],
|
||||
},
|
||||
openGraph: {
|
||||
images: [
|
||||
customization.socialPreview.url ?? absoluteHref(`~gitbook/ogimage/${page.id}`),
|
||||
],
|
||||
},
|
||||
robots: shouldIndexSpace({ space, collection }) ? 'index, follow' : 'noindex, nofollow',
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { getCollection, getSpace } from '@/lib/api';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { shouldIndexSpace } from '@/lib/seo';
|
||||
|
||||
import { SpaceParams } from '../fetch';
|
||||
import { SpaceParams } from '../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { NextRequest } from 'next/server';
|
||||
import { getRevisionPages } from '@/lib/api';
|
||||
import { pageHref } from '@/lib/links';
|
||||
|
||||
import { SpaceParams } from '../fetch';
|
||||
import { SpaceParams } from '../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { getCollection, getSpace, getSpaceCustomization } from '@/lib/api';
|
||||
import { getEmojiForCode } from '@/lib/emojis';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { SpaceParams } from '../../fetch';
|
||||
import { SpaceParams } from '../../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { ImageResponse } from 'next/og';
|
||||
import { NextRequest } from 'next/server';
|
||||
import React from 'react';
|
||||
|
||||
import { PageIdParams, fetchPageData } from '../../../fetch';
|
||||
import { PageIdParams, fetchPageData } from '../../../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import './pdf.css';
|
||||
import { PageControlButtons } from './PageControlButtons';
|
||||
import { PDFSearchParams, getPDFSearchParams } from './params';
|
||||
import { PrintButton } from './PrintButton';
|
||||
import { SpaceParams } from '../../fetch';
|
||||
import { SpaceParams } from '../../../fetch';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
getCollection,
|
||||
ContentPointer,
|
||||
getSpaceContent,
|
||||
getDocument,
|
||||
getRevisionPageByPath,
|
||||
getDocument,
|
||||
} from '@/lib/api';
|
||||
import { resolvePagePath, resolvePageId } from '@/lib/pages';
|
||||
|
||||
@@ -20,6 +20,30 @@ export interface PageIdParams extends SpaceParams {
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the space layout.
|
||||
*/
|
||||
export async function fetchSpaceData(params: PagePathParams | PageIdParams) {
|
||||
const content: ContentPointer = {
|
||||
spaceId: params.spaceId,
|
||||
changeRequestId: params.changeRequestId,
|
||||
revisionId: params.revisionId,
|
||||
};
|
||||
|
||||
const { space, pages, customization, scripts } = await getSpaceContent(content);
|
||||
const collection = await fetchParentCollection(space);
|
||||
|
||||
return {
|
||||
content,
|
||||
space,
|
||||
pages,
|
||||
customization,
|
||||
scripts,
|
||||
ancestors: [],
|
||||
...collection,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all the data needed to render the content.
|
||||
* Optimized to fetch in parallel as much as possible.
|
||||
@@ -36,7 +60,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
|
||||
const page = await resolvePage(pages, content, params);
|
||||
const [collection, document] = await Promise.all([
|
||||
fetchParentCollection(space),
|
||||
page && page.page.documentId ? await getDocument(space.id, page.page.documentId) : null,
|
||||
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -12,6 +12,10 @@ import { ClientContexts } from './ClientContexts';
|
||||
import { SpaceParams } from './fetch';
|
||||
import './globals.css';
|
||||
|
||||
/**
|
||||
* Layout shared between the content and the PDF renderer.
|
||||
* It takes care of setting the theme and the language.
|
||||
*/
|
||||
export default async function SpaceRootLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: SpaceParams;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { DocumentBlock, JSONDocument } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
SkeletonParagraph,
|
||||
SkeletonHeading,
|
||||
SkeletonCard,
|
||||
SkeletonImage,
|
||||
} from '@/components/primitives';
|
||||
import { ClassValue } from '@/lib/tailwind';
|
||||
|
||||
import { BlockContentRef } from './BlockContentRef';
|
||||
@@ -35,55 +42,103 @@ export interface BlockProps<Block extends DocumentBlock> extends DocumentContext
|
||||
export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
|
||||
const { block, style, ...contextProps } = props;
|
||||
|
||||
const content = (() => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return <Paragraph {...props} {...contextProps} block={block} />;
|
||||
case 'heading-1':
|
||||
case 'heading-2':
|
||||
case 'heading-3':
|
||||
return <Heading {...props} {...contextProps} block={block} />;
|
||||
case 'list-ordered':
|
||||
return <ListOrdered {...props} {...contextProps} block={block} />;
|
||||
case 'list-unordered':
|
||||
return <ListUnordered {...props} {...contextProps} block={block} />;
|
||||
case 'list-tasks':
|
||||
return <ListTasks {...props} {...contextProps} block={block} />;
|
||||
case 'list-item':
|
||||
return <ListItem {...props} {...contextProps} block={block} />;
|
||||
case 'code':
|
||||
return <CodeBlock {...props} {...contextProps} block={block} />;
|
||||
case 'hint':
|
||||
return <Hint {...props} {...contextProps} block={block} />;
|
||||
case 'images':
|
||||
return <Images {...props} {...contextProps} block={block} />;
|
||||
case 'tabs':
|
||||
return <Tabs {...props} {...contextProps} block={block} />;
|
||||
case 'expandable':
|
||||
return <Expandable {...props} {...contextProps} block={block} />;
|
||||
case 'table':
|
||||
return <Table {...props} {...contextProps} block={block} />;
|
||||
case 'swagger':
|
||||
return <Swagger {...props} {...contextProps} block={block} />;
|
||||
case 'embed':
|
||||
return <Embed {...props} {...contextProps} block={block} />;
|
||||
case 'blockquote':
|
||||
return <Quote {...props} {...contextProps} block={block} />;
|
||||
case 'math':
|
||||
return <BlockMath {...props} {...contextProps} block={block} />;
|
||||
case 'file':
|
||||
return <File {...props} {...contextProps} block={block} />;
|
||||
case 'divider':
|
||||
return <Divider {...props} {...contextProps} block={block} />;
|
||||
case 'drawing':
|
||||
return <Drawing {...props} {...contextProps} block={block} />;
|
||||
case 'content-ref':
|
||||
return <BlockContentRef {...props} {...contextProps} block={block} />;
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
throw new Error('Blocks should be directly rendered by parent');
|
||||
case 'integration':
|
||||
return <div>TODO Not supported yet</div>;
|
||||
default:
|
||||
assertNever(block);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<BlockPlaceholder block={block} style={style} />}>
|
||||
{content}
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
|
||||
const { block, style } = props;
|
||||
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return <Paragraph {...props} {...contextProps} block={block} />;
|
||||
case 'heading-1':
|
||||
case 'heading-2':
|
||||
case 'heading-3':
|
||||
return <Heading {...props} {...contextProps} block={block} />;
|
||||
return <SkeletonHeading style={style} />;
|
||||
case 'paragraph':
|
||||
case 'list-ordered':
|
||||
return <ListOrdered {...props} {...contextProps} block={block} />;
|
||||
case 'list-unordered':
|
||||
return <ListUnordered {...props} {...contextProps} block={block} />;
|
||||
case 'list-tasks':
|
||||
return <ListTasks {...props} {...contextProps} block={block} />;
|
||||
case 'list-item':
|
||||
return <ListItem {...props} {...contextProps} block={block} />;
|
||||
case 'code':
|
||||
return <CodeBlock {...props} {...contextProps} block={block} />;
|
||||
case 'hint':
|
||||
return <Hint {...props} {...contextProps} block={block} />;
|
||||
case 'images':
|
||||
return <Images {...props} {...contextProps} block={block} />;
|
||||
case 'tabs':
|
||||
return <Tabs {...props} {...contextProps} block={block} />;
|
||||
case 'expandable':
|
||||
return <Expandable {...props} {...contextProps} block={block} />;
|
||||
case 'table':
|
||||
return <Table {...props} {...contextProps} block={block} />;
|
||||
case 'swagger':
|
||||
return <Swagger {...props} {...contextProps} block={block} />;
|
||||
case 'embed':
|
||||
return <Embed {...props} {...contextProps} block={block} />;
|
||||
case 'blockquote':
|
||||
return <Quote {...props} {...contextProps} block={block} />;
|
||||
case 'code':
|
||||
case 'hint':
|
||||
return <SkeletonParagraph style={style} />;
|
||||
case 'tabs':
|
||||
case 'expandable':
|
||||
case 'table':
|
||||
case 'swagger':
|
||||
case 'math':
|
||||
return <BlockMath {...props} {...contextProps} block={block} />;
|
||||
case 'file':
|
||||
return <File {...props} {...contextProps} block={block} />;
|
||||
case 'divider':
|
||||
return <Divider {...props} {...contextProps} block={block} />;
|
||||
case 'drawing':
|
||||
return <Drawing {...props} {...contextProps} block={block} />;
|
||||
case 'content-ref':
|
||||
return <BlockContentRef {...props} {...contextProps} block={block} />;
|
||||
case 'integration':
|
||||
return <SkeletonCard style={style} />;
|
||||
case 'embed':
|
||||
case 'images':
|
||||
return <SkeletonImage style={style} />;
|
||||
case 'image':
|
||||
case 'code-line':
|
||||
case 'tabs-item':
|
||||
throw new Error('Blocks should be directly rendered by parent');
|
||||
case 'integration':
|
||||
return <div>TODO Not supported yet</div>;
|
||||
default:
|
||||
assertNever(block);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,15 @@ import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { FooterLinksGroup } from './FooterLinksGroup';
|
||||
import { CONTAINER_MAX_WIDTH_NORMAL, CONTAINER_PADDING } from '../layout';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { ThemeToggler } from '../ThemeToggler';
|
||||
|
||||
export function Footer(props: {
|
||||
space: Space;
|
||||
context: ContentRefContext;
|
||||
customization: CustomizationSettings;
|
||||
asFullWidth: boolean;
|
||||
}) {
|
||||
const { context, customization, asFullWidth } = props;
|
||||
const { context, customization } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -27,16 +26,7 @@ export function Footer(props: {
|
||||
'dark:bg-dark-2',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
CONTAINER_PADDING,
|
||||
asFullWidth ? null : [CONTAINER_MAX_WIDTH_NORMAL, 'mx-auto'],
|
||||
|
||||
'py-6',
|
||||
)}
|
||||
>
|
||||
<div className={tcls('flex', 'flex-row', CONTAINER_STYLE, 'py-6')}>
|
||||
<div className={tcls('flex-1', 'flex', 'flex-col', 'gap-6')}>
|
||||
{customization.footer.logo || customization.footer.groups?.length > 0 ? (
|
||||
<div className={tcls('flex', 'flex-row', 'gap-20')}>
|
||||
|
||||
@@ -2,11 +2,7 @@ import { Collection, CustomizationSettings, Space } from '@gitbook/api';
|
||||
import { CustomizationHeaderPreset } from '@gitbook/api';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import {
|
||||
CONTAINER_MAX_WIDTH_NORMAL,
|
||||
CONTAINER_PADDING,
|
||||
HEADER_HEIGHT_DESKTOP,
|
||||
} from '@/components/layout';
|
||||
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
|
||||
import { t, getSpaceLanguage } from '@/intl/server';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -24,27 +20,14 @@ export function Header(props: {
|
||||
collection: Collection | null;
|
||||
collectionSpaces: Space[];
|
||||
context: ContentRefContext;
|
||||
asFullWidth: boolean;
|
||||
customization: CustomizationSettings;
|
||||
withTopHeader?: boolean;
|
||||
}) {
|
||||
const {
|
||||
context,
|
||||
space,
|
||||
collection,
|
||||
collectionSpaces,
|
||||
asFullWidth,
|
||||
customization,
|
||||
withTopHeader,
|
||||
} = props;
|
||||
const { context, space, collection, collectionSpaces, customization, withTopHeader } = props;
|
||||
|
||||
const isCustomizationDefault =
|
||||
customization.header.preset === CustomizationHeaderPreset.Default;
|
||||
|
||||
const isCustomizationCustom = customization.header.preset === CustomizationHeaderPreset.Custom;
|
||||
|
||||
console.log('Header.tsx: isCustomizationDefault: ', isCustomizationDefault);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={tcls(
|
||||
@@ -79,8 +62,7 @@ export function Header(props: {
|
||||
'align-center',
|
||||
'justify-between',
|
||||
'w-full',
|
||||
CONTAINER_PADDING,
|
||||
asFullWidth ? null : [CONTAINER_MAX_WIDTH_NORMAL, 'mx-auto'],
|
||||
CONTAINER_STYLE,
|
||||
)}
|
||||
>
|
||||
<HeaderLogo collection={collection} space={space} customization={customization} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import React from 'react';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { t, getSpaceLanguage } from '@/intl/server';
|
||||
import { getDocument } from '@/lib/api';
|
||||
import { getDocumentSections } from '@/lib/document';
|
||||
import { absoluteHref } from '@/lib/links';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -16,7 +17,7 @@ import { PageFeedbackForm } from '../PageFeedback';
|
||||
/**
|
||||
* Aside listing the headings in the document.
|
||||
*/
|
||||
export function PageAside(props: {
|
||||
export async function PageAside(props: {
|
||||
space: Space;
|
||||
customization: CustomizationSettings;
|
||||
page: RevisionPageDocument;
|
||||
@@ -25,7 +26,8 @@ export function PageAside(props: {
|
||||
withFullPageCover: boolean;
|
||||
withPageFeedback: boolean;
|
||||
}) {
|
||||
const { space, page, customization, document, withHeaderOffset, withPageFeedback } = props;
|
||||
const { space, page, document, customization, withHeaderOffset, withPageFeedback } = props;
|
||||
|
||||
const sections = document ? getDocumentSections(document) : [];
|
||||
const language = getSpaceLanguage(customization);
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { CustomizationSettings, JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
|
||||
import React from 'react';
|
||||
|
||||
import { hasFullWidthBlock } from '@/lib/document';
|
||||
import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { PageCover } from './PageCover';
|
||||
import { PageFooterNavigation } from './PageFooterNavigation';
|
||||
import { PageHeader } from './PageHeader';
|
||||
import { PageLoading } from './PageLoading';
|
||||
import { TogglePageFullWidth } from './TogglePageFullWidth';
|
||||
import { DocumentView } from '../DocumentView';
|
||||
import { PageFeedbackForm } from '../PageFeedback';
|
||||
|
||||
@@ -15,8 +16,8 @@ export function PageBody(props: {
|
||||
space: Space;
|
||||
customization: CustomizationSettings;
|
||||
page: RevisionPageDocument;
|
||||
context: ContentRefContext;
|
||||
document: JSONDocument | null;
|
||||
context: ContentRefContext;
|
||||
withDesktopTableOfContents: boolean;
|
||||
withAside: boolean;
|
||||
withPageFeedback: boolean;
|
||||
@@ -32,60 +33,61 @@ export function PageBody(props: {
|
||||
withPageFeedback,
|
||||
} = props;
|
||||
|
||||
const asFullWidth = document ? hasFullWidthBlock(document) : false;
|
||||
|
||||
return (
|
||||
<main
|
||||
className={tcls(
|
||||
'relative',
|
||||
'py-8',
|
||||
'lg:px-12',
|
||||
'flex-1',
|
||||
withAside ? null : 'mr-56',
|
||||
withDesktopTableOfContents ? null : 'xl:ml-72',
|
||||
)}
|
||||
>
|
||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||
) : null}
|
||||
<>
|
||||
{asFullWidth ? <TogglePageFullWidth /> : null}
|
||||
<main
|
||||
className={tcls(
|
||||
'relative',
|
||||
'py-8',
|
||||
'lg:px-12',
|
||||
'flex-1',
|
||||
withAside ? null : 'mr-56',
|
||||
withDesktopTableOfContents ? null : 'xl:ml-72',
|
||||
)}
|
||||
>
|
||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||
) : null}
|
||||
|
||||
<PageHeader page={page} />
|
||||
{document ? (
|
||||
<DocumentView
|
||||
document={document}
|
||||
style={['space-y-5', 'grid']}
|
||||
context={{
|
||||
content: context.content,
|
||||
resolveContentRef: (ref) => resolveContentRef(ref, context),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<PageHeader page={page} />
|
||||
{document ? (
|
||||
<DocumentView
|
||||
document={document}
|
||||
style={['space-y-5', 'grid']}
|
||||
context={{
|
||||
content: context.content,
|
||||
resolveContentRef: (ref) => resolveContentRef(ref, context),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{page.layout.pagination ? (
|
||||
<PageFooterNavigation
|
||||
space={space}
|
||||
customization={customization}
|
||||
pages={context.pages}
|
||||
page={page}
|
||||
/>
|
||||
) : null}
|
||||
{page.layout.pagination ? (
|
||||
<PageFooterNavigation
|
||||
space={space}
|
||||
customization={customization}
|
||||
pages={context.pages}
|
||||
page={page}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{withPageFeedback ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'justify-end',
|
||||
'mt-6',
|
||||
'max-w-3xl',
|
||||
'mx-auto',
|
||||
)}
|
||||
>
|
||||
<PageFeedbackForm spaceId={space.id} pageId={page.id} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<React.Suspense fallback={null}>
|
||||
<PageLoading />
|
||||
</React.Suspense>
|
||||
</main>
|
||||
{withPageFeedback ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'justify-end',
|
||||
'mt-6',
|
||||
'max-w-3xl',
|
||||
'mx-auto',
|
||||
)}
|
||||
>
|
||||
<PageFeedbackForm spaceId={space.id} pageId={page.id} />
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Loading } from '@/components/primitives';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { useIsLoadingPage } from '../state';
|
||||
|
||||
/**
|
||||
* When navigating between pages, display an overlay on top of the page.
|
||||
*/
|
||||
export function PageLoading(props: {}) {
|
||||
const loading = useIsLoadingPage();
|
||||
|
||||
return (
|
||||
<div className={tcls('absolute', 'grid', 'inset-0', 'pointer-events-none')}>
|
||||
<div
|
||||
className={tcls(
|
||||
'grid-area-1-1',
|
||||
'bg-light',
|
||||
'grid',
|
||||
loading ? 'opacity-8' : ['opacity-0'],
|
||||
'transition-opacity',
|
||||
'dark:bg-dark',
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
className={tcls(
|
||||
'grid-area-1-1',
|
||||
'flex',
|
||||
'sticky',
|
||||
'top-0',
|
||||
'h-[100vh]',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'transition-opacity',
|
||||
loading ? 'opacity-[1]' : ['opacity-0'],
|
||||
)}
|
||||
>
|
||||
<Loading className={tcls('w-6', 'text-primary')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Client component to toggle the global class `page-full-width` on the body element.
|
||||
* It indicates that the page is rendered full width.
|
||||
*/
|
||||
export function TogglePageFullWidth() {
|
||||
React.useLayoutEffect(() => {
|
||||
document.body.classList.add('page-full-width');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('page-full-width');
|
||||
};
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -195,6 +195,7 @@ function SearchModalBody(
|
||||
query: state.query,
|
||||
});
|
||||
}}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : null}
|
||||
{state.query && state.ask && withAsk ? (
|
||||
|
||||
@@ -11,15 +11,17 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
query: string;
|
||||
item: ComputedPageResult;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
},
|
||||
ref: React.Ref<HTMLAnchorElement>,
|
||||
) {
|
||||
const { query, item, active } = props;
|
||||
const { query, item, active, onClick } = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={item.href}
|
||||
onClick={onClick}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
|
||||
@@ -33,10 +33,11 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
spaceId: string;
|
||||
withAsk: boolean;
|
||||
onSwitchToAsk: () => void;
|
||||
onClose: () => void;
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>,
|
||||
) {
|
||||
const { query, spaceId, withAsk, onSwitchToAsk } = props;
|
||||
const { query, spaceId, withAsk, onSwitchToAsk, onClose } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const debounceTimeout = React.useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -188,6 +189,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
onClick={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -228,6 +230,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
query={query}
|
||||
item={item}
|
||||
active={index === cursor}
|
||||
onClick={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,16 @@ import { HighlightQuery } from './HighlightQuery';
|
||||
import type { ComputedSectionResult } from './server-actions';
|
||||
|
||||
export const SearchSectionResultItem = React.forwardRef(function SearchSectionResultItem(
|
||||
props: { query: string; item: ComputedSectionResult; active: boolean },
|
||||
props: { query: string; item: ComputedSectionResult; active: boolean; onClick: () => void },
|
||||
ref: React.Ref<HTMLAnchorElement>,
|
||||
) {
|
||||
const { query, item, active } = props;
|
||||
const { query, item, active, onClick } = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={item.href}
|
||||
onClick={onClick}
|
||||
className={tcls(
|
||||
'search-section-result-item',
|
||||
'flex',
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import {
|
||||
Collection,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationSettings,
|
||||
JSONDocument,
|
||||
Revision,
|
||||
RevisionPageDocument,
|
||||
RevisionPageGroup,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import React from 'react';
|
||||
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { CompactHeader, Header } from '@/components/Header';
|
||||
import { CONTAINER_MAX_WIDTH_NORMAL, CONTAINER_PADDING } from '@/components/layout';
|
||||
import { PageBody, PageCover } from '@/components/PageBody';
|
||||
import { SearchModal } from '@/components/Search';
|
||||
import { TableOfContents } from '@/components/TableOfContents';
|
||||
import { ContentPointer } from '@/lib/api';
|
||||
import { hasFullWidthBlock } from '@/lib/document';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { PageAside } from '../PageAside';
|
||||
|
||||
/**
|
||||
* Render the entire content of the space (header, table of contents, footer, and page content).
|
||||
*/
|
||||
export function SpaceContent(props: {
|
||||
content: ContentPointer;
|
||||
space: Space;
|
||||
collection: Collection | null;
|
||||
collectionSpaces: Space[];
|
||||
customization: CustomizationSettings;
|
||||
pages: Revision['pages'];
|
||||
page: RevisionPageDocument;
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
|
||||
document: JSONDocument | null;
|
||||
}) {
|
||||
const {
|
||||
space,
|
||||
collection,
|
||||
collectionSpaces,
|
||||
content,
|
||||
pages,
|
||||
customization,
|
||||
page,
|
||||
ancestors,
|
||||
document,
|
||||
} = props;
|
||||
|
||||
const asFullWidth = document ? hasFullWidthBlock(document) : false;
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
const withFullPageCover = !!(
|
||||
page.cover &&
|
||||
page.layout.cover &&
|
||||
page.layout.coverSize === 'full'
|
||||
);
|
||||
const withPageFeedback = customization.feedback.enabled;
|
||||
|
||||
const contentRefContext: ContentRefContext = {
|
||||
space,
|
||||
pages,
|
||||
page,
|
||||
content,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
withTopHeader={withTopHeader}
|
||||
space={space}
|
||||
collection={collection}
|
||||
collectionSpaces={collectionSpaces}
|
||||
context={contentRefContext}
|
||||
customization={customization}
|
||||
asFullWidth={asFullWidth}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'lg:flex-row',
|
||||
CONTAINER_PADDING,
|
||||
asFullWidth ? null : [CONTAINER_MAX_WIDTH_NORMAL, 'mx-auto'],
|
||||
)}
|
||||
>
|
||||
<TableOfContents
|
||||
space={space}
|
||||
customization={customization}
|
||||
content={content}
|
||||
pages={pages}
|
||||
activePage={page}
|
||||
ancestors={ancestors}
|
||||
context={contentRefContext}
|
||||
header={
|
||||
withTopHeader ? null : (
|
||||
<CompactHeader
|
||||
space={space}
|
||||
collection={collection}
|
||||
collectionSpaces={collectionSpaces}
|
||||
customization={customization}
|
||||
/>
|
||||
)
|
||||
}
|
||||
withHeaderOffset={withTopHeader}
|
||||
visibleOnDesktop={!!page.layout.tableOfContents}
|
||||
/>
|
||||
<div className={tcls('flex-1', 'flex', 'flex-col')}>
|
||||
{withFullPageCover && page.cover ? (
|
||||
<PageCover
|
||||
as="full"
|
||||
page={page}
|
||||
cover={page.cover}
|
||||
context={contentRefContext}
|
||||
/>
|
||||
) : null}
|
||||
<div className={tcls('flex', 'flex-row')}>
|
||||
<PageBody
|
||||
space={space}
|
||||
customization={customization}
|
||||
context={contentRefContext}
|
||||
page={page}
|
||||
document={document}
|
||||
withDesktopTableOfContents={!!page.layout.tableOfContents}
|
||||
withAside={!!page.layout.outline}
|
||||
withPageFeedback={
|
||||
// Display the page feedback in the page footer if the aside is not visible
|
||||
withPageFeedback && !page.layout.outline
|
||||
}
|
||||
/>
|
||||
{page.layout.outline ? (
|
||||
<PageAside
|
||||
space={space}
|
||||
customization={customization}
|
||||
page={page}
|
||||
document={document}
|
||||
withHeaderOffset={withTopHeader}
|
||||
withFullPageCover={withFullPageCover}
|
||||
withPageFeedback={withPageFeedback}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customization.themes.toggeable ||
|
||||
customization.footer.copyright ||
|
||||
customization.footer.logo ||
|
||||
customization.footer.groups?.length ? (
|
||||
<Footer
|
||||
space={space}
|
||||
context={contentRefContext}
|
||||
customization={customization}
|
||||
asFullWidth={asFullWidth}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchModal spaceId={space.id} withAsk={customization.aiSearch.enabled} />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './SpaceContent';
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Collection,
|
||||
CustomizationHeaderPreset,
|
||||
CustomizationSettings,
|
||||
Revision,
|
||||
RevisionPageDocument,
|
||||
RevisionPageGroup,
|
||||
Space,
|
||||
} from '@gitbook/api';
|
||||
import React from 'react';
|
||||
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { CompactHeader, Header } from '@/components/Header';
|
||||
import { CONTAINER_STYLE } from '@/components/layout';
|
||||
import { SearchModal } from '@/components/Search';
|
||||
import { TableOfContents } from '@/components/TableOfContents';
|
||||
import { ContentPointer } from '@/lib/api';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Render the entire content of the space (header, table of contents, footer, and page content).
|
||||
*/
|
||||
export function SpaceLayout(props: {
|
||||
content: ContentPointer;
|
||||
space: Space;
|
||||
collection: Collection | null;
|
||||
collectionSpaces: Space[];
|
||||
customization: CustomizationSettings;
|
||||
pages: Revision['pages'];
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
space,
|
||||
collection,
|
||||
collectionSpaces,
|
||||
content,
|
||||
pages,
|
||||
customization,
|
||||
ancestors,
|
||||
children,
|
||||
// document,
|
||||
} = props;
|
||||
|
||||
// const asFullWidth = document ? hasFullWidthBlock(document) : false;
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
|
||||
const contentRefContext: ContentRefContext = {
|
||||
space,
|
||||
pages,
|
||||
content,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
withTopHeader={withTopHeader}
|
||||
space={space}
|
||||
collection={collection}
|
||||
collectionSpaces={collectionSpaces}
|
||||
context={contentRefContext}
|
||||
customization={customization}
|
||||
/>
|
||||
|
||||
<div className={tcls('flex', 'flex-col', 'lg:flex-row', CONTAINER_STYLE)}>
|
||||
<TableOfContents
|
||||
space={space}
|
||||
customization={customization}
|
||||
content={content}
|
||||
pages={pages}
|
||||
ancestors={ancestors}
|
||||
context={contentRefContext}
|
||||
header={
|
||||
withTopHeader ? null : (
|
||||
<CompactHeader
|
||||
space={space}
|
||||
collection={collection}
|
||||
collectionSpaces={collectionSpaces}
|
||||
customization={customization}
|
||||
/>
|
||||
)
|
||||
}
|
||||
withHeaderOffset={withTopHeader}
|
||||
visibleOnDesktop={true /*!!page.layout.tableOfContents */}
|
||||
/>
|
||||
<div className={tcls('flex-1', 'flex', 'flex-col')}>{children}</div>
|
||||
</div>
|
||||
|
||||
{customization.themes.toggeable ||
|
||||
customization.footer.copyright ||
|
||||
customization.footer.logo ||
|
||||
customization.footer.groups?.length ? (
|
||||
<Footer space={space} context={contentRefContext} customization={customization} />
|
||||
) : null}
|
||||
|
||||
<React.Suspense fallback={null}>
|
||||
<SearchModal spaceId={space.id} withAsk={customization.aiSearch.enabled} />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './SpaceLayout';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { pageHref } from '@/lib/links';
|
||||
import { getPagePath } from '@/lib/pages';
|
||||
import { ContentRefContext } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -11,64 +11,18 @@ import { ToggleableLinkItem } from './ToggleableLinkItem';
|
||||
export function PageDocumentItem(props: {
|
||||
rootPages: RevisionPage[];
|
||||
page: RevisionPageDocument;
|
||||
activePage: RevisionPageDocument;
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
|
||||
context: ContentRefContext;
|
||||
}) {
|
||||
const { rootPages, page, activePage, ancestors, context } = props;
|
||||
|
||||
const hasActiveDescendant = ancestors.some((ancestor) => ancestor.id === page.id);
|
||||
|
||||
const linkProps = {
|
||||
href: pageHref(rootPages, page),
|
||||
className: tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'justify-between',
|
||||
'pl-5',
|
||||
'pr-1.5',
|
||||
'py-1.5',
|
||||
'text-sm',
|
||||
'transition-colors',
|
||||
'relative',
|
||||
'textwrap-balance',
|
||||
'before:border-l',
|
||||
'before:absolute',
|
||||
'before:left-[-1px]',
|
||||
'before:top-0',
|
||||
'before:h-full',
|
||||
'rounded-md',
|
||||
'[&+div_a]:rounded-l-none',
|
||||
activePage.id === page.id
|
||||
? [
|
||||
'before:border-primary/6',
|
||||
'font-semibold',
|
||||
'text-primary',
|
||||
'hover:bg-primary/3',
|
||||
'dark:text-primary-400',
|
||||
'hover:before:border-primary',
|
||||
'dark:hover:bg-primary-500/3',
|
||||
'dark:hover:before:border-primary',
|
||||
]
|
||||
: [
|
||||
'before:border-transparent',
|
||||
'font-normal',
|
||||
'text-dark/8',
|
||||
'hover:bg-dark/1',
|
||||
'hover:before:border-dark/3',
|
||||
'dark:text-light/7',
|
||||
'dark:hover:bg-light/2',
|
||||
'dark:hover:before:border-light/3',
|
||||
],
|
||||
),
|
||||
};
|
||||
const { rootPages, page, ancestors, context } = props;
|
||||
|
||||
return (
|
||||
<li className={tcls('flex', 'flex-col')}>
|
||||
{page.pages && page.pages.length ? (
|
||||
<ToggleableLinkItem
|
||||
{...linkProps}
|
||||
descendants={
|
||||
<ToggleableLinkItem
|
||||
href={pageHref(rootPages, page)}
|
||||
pathname={getPagePath(rootPages, page)}
|
||||
descendants={
|
||||
page.pages && page.pages.length ? (
|
||||
<PagesList
|
||||
rootPages={rootPages}
|
||||
pages={page.pages}
|
||||
@@ -79,19 +33,14 @@ export function PageDocumentItem(props: {
|
||||
'border-dark/3',
|
||||
'dark:border-light/2',
|
||||
)}
|
||||
activePage={activePage}
|
||||
ancestors={ancestors}
|
||||
context={context}
|
||||
/>
|
||||
}
|
||||
defaultOpen={hasActiveDescendant || activePage.id === page.id}
|
||||
isActive={activePage.id === page.id}
|
||||
>
|
||||
{page.title}
|
||||
</ToggleableLinkItem>
|
||||
) : (
|
||||
<Link {...linkProps}>{page.title}</Link>
|
||||
)}
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{page.title}
|
||||
</ToggleableLinkItem>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,10 @@ import { PagesList } from './PagesList';
|
||||
export function PageGroupItem(props: {
|
||||
rootPages: RevisionPage[];
|
||||
page: RevisionPageGroup;
|
||||
activePage: RevisionPageDocument;
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
|
||||
context: ContentRefContext;
|
||||
}) {
|
||||
const { rootPages, page, activePage, ancestors, context } = props;
|
||||
const { rootPages, page, ancestors, context } = props;
|
||||
|
||||
return (
|
||||
<li className={tcls('flex', 'flex-col')}>
|
||||
@@ -43,7 +42,6 @@ export function PageGroupItem(props: {
|
||||
<PagesList
|
||||
rootPages={rootPages}
|
||||
pages={page.pages}
|
||||
activePage={activePage}
|
||||
ancestors={ancestors}
|
||||
context={context}
|
||||
/>
|
||||
|
||||
@@ -10,12 +10,11 @@ import { PageLinkItem } from './PageLinkItem';
|
||||
export function PagesList(props: {
|
||||
rootPages: RevisionPage[];
|
||||
pages: RevisionPage[];
|
||||
activePage: RevisionPageDocument;
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
|
||||
context: ContentRefContext;
|
||||
style?: ClassValue;
|
||||
}) {
|
||||
const { rootPages, pages, activePage, ancestors, context, style } = props;
|
||||
const { rootPages, pages, ancestors, context, style } = props;
|
||||
|
||||
return (
|
||||
<ul className={tcls('flex', 'flex-1', 'flex-col', 'gap-y-0.5', style)}>
|
||||
@@ -26,7 +25,6 @@ export function PagesList(props: {
|
||||
key={page.id}
|
||||
rootPages={rootPages}
|
||||
page={page}
|
||||
activePage={activePage}
|
||||
ancestors={ancestors}
|
||||
context={context}
|
||||
/>
|
||||
@@ -40,7 +38,6 @@ export function PagesList(props: {
|
||||
key={page.id}
|
||||
rootPages={rootPages}
|
||||
page={page}
|
||||
activePage={activePage}
|
||||
ancestors={ancestors}
|
||||
context={context}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,6 @@ export function TableOfContents(props: {
|
||||
content: ContentPointer;
|
||||
context: ContentRefContext;
|
||||
pages: Revision['pages'];
|
||||
activePage: RevisionPageDocument;
|
||||
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
|
||||
header?: React.ReactNode;
|
||||
withHeaderOffset: boolean;
|
||||
@@ -30,7 +29,6 @@ export function TableOfContents(props: {
|
||||
space,
|
||||
customization,
|
||||
pages,
|
||||
activePage,
|
||||
ancestors,
|
||||
header,
|
||||
context,
|
||||
@@ -95,7 +93,6 @@ export function TableOfContents(props: {
|
||||
<PagesList
|
||||
rootPages={pages}
|
||||
pages={pages}
|
||||
activePage={activePage}
|
||||
ancestors={ancestors}
|
||||
context={context}
|
||||
/>
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
import IconChevronRight from '@geist-ui/icons/chevronRight';
|
||||
import { motion, stagger, useAnimate } from 'framer-motion';
|
||||
import Link, { LinkProps } from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSelectedLayoutSegment } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Client component to allow toggling of a page's children.
|
||||
*/
|
||||
|
||||
const show = {
|
||||
opacity: 1,
|
||||
height: 'auto',
|
||||
@@ -25,30 +22,53 @@ const hide = {
|
||||
},
|
||||
};
|
||||
|
||||
export function ToggleableLinkItem(
|
||||
props: LinkProps & {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
descendants?: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const { children, descendants, defaultOpen = false, isActive = false, ...linkProps } = props;
|
||||
const staggerMenuItems = stagger(0.02, { ease: (p) => Math.pow(p, 2) });
|
||||
const staggerMenuItems = stagger(0.02, { ease: (p) => Math.pow(p, 2) });
|
||||
|
||||
/**
|
||||
* Client component for a page document to toggle its children and be marked as active.
|
||||
*/
|
||||
export function ToggleableLinkItem(props: {
|
||||
href: string;
|
||||
pathname: string;
|
||||
children: React.ReactNode;
|
||||
descendants: React.ReactNode;
|
||||
}) {
|
||||
const { href, children, descendants, pathname } = props;
|
||||
|
||||
const activeSegment = useSelectedLayoutSegment() ?? '';
|
||||
|
||||
const isActive = activeSegment === pathname;
|
||||
const hasDescendants = !!descendants;
|
||||
const hasActiveDescendant =
|
||||
hasDescendants && (isActive || activeSegment.startsWith(pathname + '/'));
|
||||
|
||||
const [scope, animate] = useAnimate();
|
||||
const [isVisible, setIsVisible] = useState(defaultOpen);
|
||||
const [isVisible, setIsVisible] = React.useState(hasActiveDescendant);
|
||||
|
||||
const toggleVisibility = () => {
|
||||
const willBecomeVisible = !isVisible;
|
||||
setIsVisible(willBecomeVisible);
|
||||
// Update the visibility of the children, if we are navigating to a descendant.
|
||||
React.useEffect(() => {
|
||||
if (!hasDescendants) {
|
||||
return;
|
||||
}
|
||||
setIsVisible((prev) => prev || hasActiveDescendant);
|
||||
|
||||
animate(scope.current, willBecomeVisible ? show : hide, {
|
||||
if (hasActiveDescendant) {
|
||||
console.log('activeSegment', { isActive, activeSegment, pathname });
|
||||
}
|
||||
}, [hasActiveDescendant, hasDescendants]);
|
||||
|
||||
// Animate the visibility of the children
|
||||
// only after the initial state.
|
||||
React.useEffect(() => {
|
||||
if (!mountedRef.current || !hasDescendants) {
|
||||
return;
|
||||
}
|
||||
|
||||
animate(scope.current, isVisible ? show : hide, {
|
||||
duration: 0.1,
|
||||
});
|
||||
|
||||
if (willBecomeVisible)
|
||||
if (isVisible)
|
||||
animate(
|
||||
'& > ul > li',
|
||||
{ opacity: 1 },
|
||||
@@ -56,65 +76,119 @@ export function ToggleableLinkItem(
|
||||
delay: staggerMenuItems,
|
||||
},
|
||||
);
|
||||
else animate('& > ul > li', { opacity: 0 });
|
||||
};
|
||||
else {
|
||||
animate('& > ul > li', { opacity: 0 });
|
||||
}
|
||||
}, [isVisible, hasDescendants]);
|
||||
|
||||
// Track if the component is mounted.
|
||||
const mountedRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link {...linkProps}>
|
||||
<Link
|
||||
href={href}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'justify-between',
|
||||
'pl-5',
|
||||
'pr-1.5',
|
||||
'py-1.5',
|
||||
'text-sm',
|
||||
'transition-colors',
|
||||
'relative',
|
||||
'textwrap-balance',
|
||||
'before:border-l',
|
||||
'before:absolute',
|
||||
'before:left-[-1px]',
|
||||
'before:top-0',
|
||||
'before:h-full',
|
||||
'rounded-md',
|
||||
'[&+div_a]:rounded-l-none',
|
||||
isActive
|
||||
? [
|
||||
'before:border-primary/6',
|
||||
'font-semibold',
|
||||
'text-primary',
|
||||
'hover:bg-primary/3',
|
||||
'dark:text-primary-400',
|
||||
'hover:before:border-primary',
|
||||
'dark:hover:bg-primary-500/3',
|
||||
'dark:hover:before:border-primary',
|
||||
]
|
||||
: [
|
||||
'before:border-transparent',
|
||||
'font-normal',
|
||||
'text-dark/8',
|
||||
'hover:bg-dark/1',
|
||||
'hover:before:border-dark/3',
|
||||
'dark:text-light/7',
|
||||
'dark:hover:bg-light/2',
|
||||
'dark:hover:before:border-light/3',
|
||||
],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span
|
||||
className={tcls(
|
||||
'group',
|
||||
'relative',
|
||||
'rounded-full',
|
||||
'w-5',
|
||||
'h-5',
|
||||
'after:grid-area-1-1',
|
||||
'after:absolute',
|
||||
'after:-top-1',
|
||||
'after:grid',
|
||||
'after:-left-1',
|
||||
'after:w-7',
|
||||
'after:h-7',
|
||||
'hover:bg-dark/2',
|
||||
'hover:text-current',
|
||||
'dark:hover:bg-light/2',
|
||||
'dark:hover:text-current',
|
||||
isActive ? ['hover:bg-primary/4', 'dark:hover:bg-primary/4'] : [],
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleVisibility();
|
||||
}}
|
||||
>
|
||||
<IconChevronRight
|
||||
{hasDescendants ? (
|
||||
<span
|
||||
className={tcls(
|
||||
'grid',
|
||||
'flex-shrink-0',
|
||||
'group',
|
||||
'relative',
|
||||
'rounded-full',
|
||||
'w-5',
|
||||
'h-5',
|
||||
'p-0.5',
|
||||
'[&>path]:transition-[stroke-opacity]',
|
||||
'text-current',
|
||||
'transition-transform',
|
||||
'[&>path]:[stroke-opacity:0.40]',
|
||||
'group-hover:[&>path]:[stroke-opacity:1]',
|
||||
|
||||
isVisible ? ['rotate-90'] : ['rotate-0'],
|
||||
'after:grid-area-1-1',
|
||||
'after:absolute',
|
||||
'after:-top-1',
|
||||
'after:grid',
|
||||
'after:-left-1',
|
||||
'after:w-7',
|
||||
'after:h-7',
|
||||
'hover:bg-dark/2',
|
||||
'hover:text-current',
|
||||
'dark:hover:bg-light/2',
|
||||
'dark:hover:text-current',
|
||||
isActive ? ['hover:bg-primary/4', 'dark:hover:bg-primary/4'] : [],
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsVisible((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<IconChevronRight
|
||||
className={tcls(
|
||||
'grid',
|
||||
'flex-shrink-0',
|
||||
'w-5',
|
||||
'h-5',
|
||||
'p-0.5',
|
||||
'[&>path]:transition-[stroke-opacity]',
|
||||
'text-current',
|
||||
'transition-transform',
|
||||
'[&>path]:[stroke-opacity:0.40]',
|
||||
'group-hover:[&>path]:[stroke-opacity:1]',
|
||||
|
||||
isVisible ? ['rotate-90'] : ['rotate-0'],
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
{/* TODO: fix recursive issue */}
|
||||
<motion.div
|
||||
ref={scope}
|
||||
className={tcls(isVisible ? null : '[&_ul>li]:opacity-1')}
|
||||
initial={isVisible ? show : hide}
|
||||
>
|
||||
{descendants}
|
||||
</motion.div>
|
||||
{hasDescendants ? (
|
||||
<motion.div
|
||||
ref={scope}
|
||||
className={tcls(isVisible ? null : '[&_ul>li]:opacity-1')}
|
||||
initial={isVisible ? show : hide}
|
||||
>
|
||||
{descendants}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@ import { ClassValue } from '@/lib/tailwind';
|
||||
export const HEADER_HEIGHT_DESKTOP = 64 as const;
|
||||
|
||||
/**
|
||||
* Maximum width of the normal mode.
|
||||
* Style for the container to adapt between normal and full width.
|
||||
*/
|
||||
export const CONTAINER_MAX_WIDTH_NORMAL = 'max-w-screen-2xl';
|
||||
|
||||
/**
|
||||
* Padding of the container.
|
||||
*/
|
||||
export const CONTAINER_PADDING: ClassValue = ['px-4', 'sm:px-6', 'md:px-8'];
|
||||
export const CONTAINER_STYLE: ClassValue = [
|
||||
'px-4',
|
||||
'sm:px-6',
|
||||
'md:px-8',
|
||||
'max-w-screen-2xl',
|
||||
'mx-auto',
|
||||
'page-full-width:max-w-full',
|
||||
];
|
||||
|
||||
/**
|
||||
* Height of the page cover.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
/**
|
||||
* Placeholder to be used when a content is not yet loaded (in a React.Suspense boundary).
|
||||
* It's used when streaming the content of a page.
|
||||
*/
|
||||
export function SkeletonParagraph(props: { style?: ClassValue }) {
|
||||
const { style } = props;
|
||||
return (
|
||||
<div role="status" className={tcls('animate-pulse', style)}>
|
||||
<div className="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-full mb-4"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[70%] mb-2.5"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[70%] mb-2.5"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[80%] mb-2.5"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[70%]"></div>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder when loading a title.
|
||||
*/
|
||||
export function SkeletonHeading(props: { style?: ClassValue }) {
|
||||
const { style } = props;
|
||||
return (
|
||||
<div role="status" className={tcls('animate-pulse', style)}>
|
||||
<div className="h-6 bg-gray-200 rounded-full dark:bg-gray-700 w-[50%] mb-4"></div>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder when loading an asset (image, video, etc.)
|
||||
*/
|
||||
export function SkeletonImage(props: { style?: ClassValue }) {
|
||||
const { style } = props;
|
||||
return (
|
||||
<div role="status" className={tcls('animate-pulse', style)}>
|
||||
<div className="flex items-center justify-center w-full h-48 bg-gray-300 rounded dark:bg-gray-700">
|
||||
<svg
|
||||
className="w-10 h-10 text-gray-200 dark:text-gray-600"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 18"
|
||||
>
|
||||
<path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder when loading a card
|
||||
*/
|
||||
export function SkeletonCard(props: { style?: ClassValue }) {
|
||||
const { style } = props;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={tcls('animate-pulse', 'p-4 border border-gray-200', 'rounded', style)}
|
||||
>
|
||||
<div className="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px] mb-2.5"></div>
|
||||
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './Checkbox';
|
||||
export * from './Button';
|
||||
export * from './Loading';
|
||||
export * from './Card';
|
||||
export * from './Skeleton';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './useIsLoadingPage';
|
||||
@@ -1,37 +0,0 @@
|
||||
import { atom, useRecoilValue } from 'recoil';
|
||||
|
||||
const loadingPageAtom = atom({
|
||||
key: 'loadingPage',
|
||||
default: false,
|
||||
effects: [
|
||||
({ setSelf }) => {
|
||||
// @ts-ignore
|
||||
if (typeof window === 'undefined' || !window.navigation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
window.navigation.addEventListener('navigate', () => {
|
||||
// Next.js finished fetching the page and update the URL.
|
||||
setSelf(false);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
// @ts-ignore
|
||||
if (!event.target || event.target.tagName !== 'A') {
|
||||
return;
|
||||
}
|
||||
|
||||
// CLick on a <Link> component.
|
||||
setSelf(true);
|
||||
});
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Return true if we are loading a new page.
|
||||
*/
|
||||
export function useIsLoadingPage(): boolean {
|
||||
return useRecoilValue(loadingPageAtom);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface ContentRefContext extends PageHrefContext {
|
||||
content: ContentPointer;
|
||||
space: Space;
|
||||
pages: Revision['pages'];
|
||||
page: RevisionPageDocument;
|
||||
page?: RevisionPageDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,7 @@ export async function resolveContentRef(
|
||||
(contentRef.space ?? space.id) === space.id
|
||||
) {
|
||||
const page =
|
||||
!contentRef.page || contentRef.page === activePage.id
|
||||
!contentRef.page || contentRef.page === activePage?.id
|
||||
? activePage
|
||||
: resolvePageId(pages, contentRef.page)?.page;
|
||||
if (!page) {
|
||||
@@ -65,7 +65,7 @@ export async function resolveContentRef(
|
||||
return {
|
||||
href: pageHref(pages, page, linksContext),
|
||||
text: page.title,
|
||||
active: page.id === activePage.id,
|
||||
active: page.id === activePage?.id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ const config: Config = {
|
||||
plugin(function ({ addVariant }) {
|
||||
addVariant('navigation-open', 'body.navigation-open &');
|
||||
addVariant('search-open', 'body.search-open &');
|
||||
addVariant('page-full-width', 'body.page-full-width &');
|
||||
}),
|
||||
containerQueries,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user