mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 14:45:17 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38226b9413 | |||
| b932e4e2e9 | |||
| ca4a3e8c9a | |||
| b498521196 | |||
| 8a8cf2025d | |||
| ed7d47d9b8 | |||
| b5494cc04b |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Simplify AI Chat
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix robots.txt preventing favicon from being indexed.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Show a toolbar for authenticated users with access to the current site.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Scope search across sections and variants
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"packages/expr": {
|
||||
"name": "@gitbook/expr",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-loose": "8.4.0",
|
||||
@@ -95,7 +95,7 @@
|
||||
},
|
||||
"packages/gitbook": {
|
||||
"name": "gitbook",
|
||||
"version": "0.17.1",
|
||||
"version": "0.18.0",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/browser-types": "workspace:*",
|
||||
@@ -142,6 +142,7 @@
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.4.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
@@ -197,7 +198,7 @@
|
||||
},
|
||||
"packages/icons": {
|
||||
"name": "@gitbook/icons",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"bin": {
|
||||
"gitbook-icons": "./bin/gitbook-icons.js",
|
||||
},
|
||||
@@ -233,7 +234,7 @@
|
||||
},
|
||||
"packages/react-contentkit": {
|
||||
"name": "@gitbook/react-contentkit",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.5",
|
||||
"dependencies": {
|
||||
"@gitbook/api": "catalog:",
|
||||
"@gitbook/icons": "workspace:*",
|
||||
@@ -265,7 +266,7 @@
|
||||
},
|
||||
"packages/react-openapi": {
|
||||
"name": "@gitbook/react-openapi",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@gitbook/expr": "workspace:*",
|
||||
"@gitbook/openapi-parser": "workspace:*",
|
||||
@@ -2432,6 +2433,12 @@
|
||||
|
||||
"mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="],
|
||||
|
||||
"motion": ["motion@12.23.12", "", { "dependencies": { "framer-motion": "^12.23.12", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.23.12", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -4352,6 +4359,10 @@
|
||||
|
||||
"minizlib/minipass": ["minipass@2.9.0", "", { "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg=="],
|
||||
|
||||
"motion/framer-motion": ["framer-motion@12.23.12", "", { "dependencies": { "motion-dom": "^12.23.12", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg=="],
|
||||
|
||||
"motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="],
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"memoizee": "^0.4.17",
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.4.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^2.2.3",
|
||||
|
||||
@@ -137,7 +137,7 @@ export function useAI(): AIContext {
|
||||
setSearchState((prev) => ({
|
||||
ask: null, // Reset ask as we assume the assistant will handle it
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
assistant.open(query);
|
||||
|
||||
@@ -21,6 +21,7 @@ export type AIChatMessage = {
|
||||
role: AIMessageRole;
|
||||
content: React.ReactNode;
|
||||
query?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type AIChatPendingTool = {
|
||||
@@ -146,7 +147,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: prev?.ask ?? initialQuery ?? '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false, // Close search popover when opening chat
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
@@ -159,7 +160,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: null,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
@@ -374,7 +375,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: input.message,
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}
|
||||
@@ -391,11 +392,11 @@ export function AIChatProvider(props: {
|
||||
return {
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
role: AIMessageRole.User,
|
||||
content: input.message,
|
||||
query: input.message,
|
||||
note: state.messages.length > 0 ? 'Starting a new chat' : undefined,
|
||||
},
|
||||
],
|
||||
query: input.message,
|
||||
@@ -435,7 +436,7 @@ export function AIChatProvider(props: {
|
||||
setSearchState((prev) => ({
|
||||
ask: '',
|
||||
query: prev?.query ?? null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: false,
|
||||
}));
|
||||
}, [setSearchState]);
|
||||
|
||||
@@ -6,14 +6,13 @@ import type React from 'react';
|
||||
import type { AIChatController, AIChatState } from '../AI';
|
||||
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||
import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
||||
|
||||
export function AIChatMessages(props: {
|
||||
chat: AIChatState;
|
||||
chatController: AIChatController;
|
||||
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const { chat, chatController, lastUserMessageRef } = props;
|
||||
const { chat, lastUserMessageRef } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -24,57 +23,63 @@ export function AIChatMessages(props: {
|
||||
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
||||
data-testid="ai-chat-message"
|
||||
className={tcls(
|
||||
message.content ? 'animate-fade-in-slow' : '',
|
||||
'shrink-0',
|
||||
'last:min-h-[calc(100%-5rem)]',
|
||||
'scroll-mt-36',
|
||||
'lg:scroll-mt-0',
|
||||
'flex flex-col gap-6',
|
||||
'break-words',
|
||||
'group/message',
|
||||
message.role === AIMessageRole.User
|
||||
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
: 'text-tint-strong'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
||||
}}
|
||||
key={index}
|
||||
>
|
||||
{message.content ? message.content : null}
|
||||
|
||||
{isLastMessage && chat.loading ? (
|
||||
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
|
||||
{!message.content ? <HoldMessage /> : null}
|
||||
<LoadingSkeleton />
|
||||
<>
|
||||
{message.note ? (
|
||||
<div
|
||||
key={`${index}-note`}
|
||||
className="self-center text-tint-subtle text-xs"
|
||||
>
|
||||
{message.note}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
||||
data-testid="ai-chat-message"
|
||||
className={tcls(
|
||||
message.content ? 'animate-fade-in-slow' : '',
|
||||
'shrink-0',
|
||||
'last:min-h-[calc(100%-5rem)]',
|
||||
'scroll-mt-36',
|
||||
'lg:scroll-mt-0',
|
||||
'flex flex-col gap-6',
|
||||
'break-words',
|
||||
'group/message',
|
||||
message.role === AIMessageRole.User
|
||||
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||
: 'text-tint-strong'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
||||
}}
|
||||
key={index}
|
||||
>
|
||||
{message.content ? message.content : null}
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
<AIChatFollowupSuggestions
|
||||
chat={chat}
|
||||
chatController={chatController}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{isLastMessage && chat.loading ? (
|
||||
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
|
||||
{!message.content ? <HoldMessage /> : null}
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLastMessage ? (
|
||||
<>
|
||||
{!chat.loading &&
|
||||
!chat.error &&
|
||||
chat.query &&
|
||||
chat.responseId &&
|
||||
chat.pendingTools.length === 0 ? (
|
||||
<AIResponseFeedback
|
||||
responseId={chat.responseId}
|
||||
query={chat.query}
|
||||
className="-ml-1 -mt-4"
|
||||
/>
|
||||
) : null}
|
||||
<AIChatToolConfirmations chat={chat} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -1,143 +1,63 @@
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { AdminToolbarClient } from './AdminToolbarClient';
|
||||
import type { AdminToolbarContext } from './types';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshChangeRequestButton } from './RefreshChangeRequestButton';
|
||||
import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar';
|
||||
|
||||
interface AdminToolbarProps {
|
||||
export interface AdminToolbarProps {
|
||||
context: GitBookSiteContext;
|
||||
}
|
||||
|
||||
function ToolbarLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'fixed',
|
||||
'bottom-5',
|
||||
'left-1/2',
|
||||
'z-50',
|
||||
'transform',
|
||||
'-translate-x-1/2',
|
||||
'rounded-full',
|
||||
|
||||
'bg-tint-12/9',
|
||||
'dark:bg-tint-1/9',
|
||||
|
||||
'shadow-lg',
|
||||
'min-h-10',
|
||||
'min-w-40',
|
||||
'p-2',
|
||||
'max-w-md',
|
||||
'border-tint-12/1',
|
||||
'backdrop-blur-md'
|
||||
)}
|
||||
>
|
||||
<React.Suspense fallback={null}>{props.children}</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar with information for the content admin when previewing a revision or change-request.
|
||||
* Server component that determines what type of toolbar to show and passes data to client component
|
||||
*/
|
||||
export async function AdminToolbar(props: AdminToolbarProps) {
|
||||
const { context } = props;
|
||||
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<ChangeRequestToolbar context={context} />
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
// Create a minimal context to avoid serializing and passing too many data to the client
|
||||
const minimalContext: AdminToolbarContext = {
|
||||
organizationId: context.organizationId,
|
||||
revisionId: context.revisionId,
|
||||
space: {
|
||||
id: context.space.id,
|
||||
revision: context.space.revision,
|
||||
urls: {
|
||||
app: context.space.urls.app,
|
||||
},
|
||||
},
|
||||
changeRequest: context.changeRequest
|
||||
? {
|
||||
id: context.changeRequest.id,
|
||||
number: context.changeRequest.number,
|
||||
subject: context.changeRequest.subject,
|
||||
revision: context.changeRequest.revision,
|
||||
updatedAt: context.changeRequest.updatedAt,
|
||||
createdBy: {
|
||||
displayName: context.changeRequest.createdBy.displayName,
|
||||
},
|
||||
urls: {
|
||||
app: context.changeRequest.urls.app,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
revision: {
|
||||
createdAt: context.revision.createdAt,
|
||||
urls: {
|
||||
app: context.revision.urls.app,
|
||||
},
|
||||
git: context.revision.git
|
||||
? {
|
||||
url: context.revision.git.url,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
site: {
|
||||
id: context.site.id,
|
||||
title: context.site.title,
|
||||
urls: {
|
||||
app: context.site.urls.app,
|
||||
published: context.site.urls.published,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<RevisionToolbar context={context} />
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const { space, changeRequest } = context;
|
||||
|
||||
if (!changeRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarLayout>
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||
<Icon icon="code-branch" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
#{changeRequest.number}: {changeRequest.subject ?? 'No subject'}
|
||||
</p>
|
||||
<p className="text-tint-2 text-xs dark:text-tint-11">
|
||||
Change request updated <DateRelative value={changeRequest.updatedAt} />
|
||||
</p>
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<ToolbarButton title="Open in application" href={changeRequest.urls.app}>
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<RefreshChangeRequestButton
|
||||
spaceId={space.id}
|
||||
changeRequestId={changeRequest.id}
|
||||
revisionId={changeRequest.revision}
|
||||
updatedAt={new Date(changeRequest.updatedAt).getTime()}
|
||||
/>
|
||||
</ToolbarButtonGroups>
|
||||
</Toolbar>
|
||||
</ToolbarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
async function RevisionToolbar(props: { context: GitBookSiteContext }) {
|
||||
const { context } = props;
|
||||
const { revision } = context;
|
||||
|
||||
return (
|
||||
<ToolbarLayout>
|
||||
<Toolbar>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="code-commit" className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarBody>
|
||||
<p>
|
||||
Revision created <DateRelative value={revision.createdAt} />
|
||||
</p>
|
||||
{revision.git ? (
|
||||
<p className="text-tint-2 text-xs dark:text-tint-11">
|
||||
{revision.git.message}
|
||||
</p>
|
||||
) : null}
|
||||
</ToolbarBody>
|
||||
<ToolbarButtonGroups>
|
||||
<ToolbarButton title="Open in application" href={revision.urls.app}>
|
||||
<Icon icon="arrow-up-right-from-square" className="size-4" />
|
||||
</ToolbarButton>
|
||||
{revision.git?.url ? (
|
||||
<ToolbarButton title="Open git commit" href={revision.git.url}>
|
||||
<Icon
|
||||
icon={revision.git.url.includes('github.com') ? 'github' : 'gitlab'}
|
||||
className="size-4"
|
||||
/>
|
||||
</ToolbarButton>
|
||||
) : null}
|
||||
</ToolbarButtonGroups>
|
||||
</Toolbar>
|
||||
</ToolbarLayout>
|
||||
);
|
||||
return <AdminToolbarClient context={minimalContext} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { MotionConfig } from 'motion/react';
|
||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||
import { useVisitorSession } from '../Insights';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshContentButton } from './RefreshContentButton';
|
||||
import {
|
||||
Toolbar,
|
||||
ToolbarBody,
|
||||
ToolbarButton,
|
||||
ToolbarButtonGroup,
|
||||
type ToolbarButtonProps,
|
||||
ToolbarSeparator,
|
||||
ToolbarSubtitle,
|
||||
ToolbarTitle,
|
||||
} from './Toolbar';
|
||||
import type { AdminToolbarClientProps } from './types';
|
||||
|
||||
export function AdminToolbarClient(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
const visitorSession = useVisitorSession();
|
||||
|
||||
// If there is a change request, show the change request toolbar
|
||||
if (context.changeRequest) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<ChangeRequestToolbar context={context} />
|
||||
</MotionConfig>
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the revision is not the current revision, the user is looking at a previous version of the site, so show the revision toolbar
|
||||
if (context.revisionId !== context.space.revision) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<RevisionToolbar context={context} />
|
||||
</MotionConfig>
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// If the user is authenticated and part of the organization owning this site, show the authenticated user toolbar
|
||||
if (visitorSession?.organizationId === context.organizationId) {
|
||||
return (
|
||||
<IframeWrapper>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<AuthenticatedUserToolbar context={context} />
|
||||
</MotionConfig>
|
||||
</IframeWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ChangeRequestToolbar(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
const { changeRequest, site } = context;
|
||||
if (!changeRequest) {
|
||||
throw new Error('Change request is not set');
|
||||
}
|
||||
|
||||
const author = changeRequest.createdBy.displayName;
|
||||
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: changeRequest.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle
|
||||
prefix="Change request"
|
||||
suffix={`#${changeRequest.number} ${changeRequest.subject || 'Untitled'}`}
|
||||
/>
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
<DateRelative value={changeRequest.updatedAt} /> by {author}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
<ToolbarButtonGroup>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
{/* Comment in app */}
|
||||
<ToolbarButton
|
||||
title="Comment in a GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${changeRequest.urls.app}~/comments`,
|
||||
siteId: site.id,
|
||||
buttonId: 'comment',
|
||||
})}
|
||||
icon="comment"
|
||||
/>
|
||||
|
||||
{/* Open production site */}
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open production site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Open CR in GitBook */}
|
||||
<ToolbarButton
|
||||
title="View change request in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: changeRequest.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'change-request',
|
||||
})}
|
||||
icon="code-pull-request"
|
||||
/>
|
||||
|
||||
{/* Edit in GitBook */}
|
||||
<EditPageButton href={changeRequest.urls.app} siteId={site.id} />
|
||||
</ToolbarButtonGroup>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function RevisionToolbar(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
const { revision, site } = context;
|
||||
if (!revision) {
|
||||
throw new Error('Revision is not set');
|
||||
}
|
||||
|
||||
const gitURL = revision.git?.url;
|
||||
const isGitHub = gitURL?.includes('github.com');
|
||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Created <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButtonGroup>
|
||||
{/* Open commit in Git client */}
|
||||
<ToolbarButton
|
||||
title={
|
||||
gitURL ? (
|
||||
`Open commit in ${gitProvider}`
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
Setup GitSync to edit using Git{' '}
|
||||
<div className="flex items-center gap-1 text-neutral-8 text-xs hover:text-neutral-6 hover:underline dark:text-neutral-3">
|
||||
<a
|
||||
href="https://gitbook.com/docs/getting-started/git-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
<Icon icon="arrow-up-right" className="size-3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
href={gitURL}
|
||||
disabled={!gitURL}
|
||||
icon={gitURL ? (isGitHub ? 'github' : 'gitlab') : 'github'}
|
||||
/>
|
||||
{site.urls.published ? (
|
||||
<ToolbarButton
|
||||
title="Open production site"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.published,
|
||||
siteId: site.id,
|
||||
buttonId: 'production-site',
|
||||
})}
|
||||
icon="globe"
|
||||
/>
|
||||
) : null}
|
||||
<ToolbarButton
|
||||
title="View this revision in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: revision.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'revision',
|
||||
})}
|
||||
icon="code-commit"
|
||||
/>
|
||||
</ToolbarButtonGroup>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedUserToolbar(props: AdminToolbarClientProps) {
|
||||
const { context } = props;
|
||||
const { revision, space, site } = context;
|
||||
const { refreshForUpdates, updated } = useCheckForContentUpdate({
|
||||
revisionId: space.revision,
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site" suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Updated <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarButtonGroup>
|
||||
{/* Refresh to retrieve latest changes */}
|
||||
{updated ? <RefreshContentButton refreshForUpdates={refreshForUpdates} /> : null}
|
||||
<ToolbarButton
|
||||
title="Open site in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'site',
|
||||
})}
|
||||
icon="gear"
|
||||
/>
|
||||
<ToolbarButton
|
||||
title="Customize in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/customization/general`,
|
||||
siteId: site.id,
|
||||
buttonId: 'customize',
|
||||
})}
|
||||
icon="palette"
|
||||
/>
|
||||
<ToolbarButton
|
||||
title="Open insights in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${site.urls.app}/insights`,
|
||||
siteId: site.id,
|
||||
buttonId: 'insights',
|
||||
})}
|
||||
icon="chart-simple"
|
||||
/>
|
||||
<EditPageButton href={space.urls.app} siteId={site.id} />
|
||||
</ToolbarButtonGroup>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPageButton(props: {
|
||||
href: string;
|
||||
siteId: string;
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}) {
|
||||
const { href, motionValues, siteId } = props;
|
||||
const pagePath = useCurrentPagePath();
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Edit in GitBook"
|
||||
href={getToolbarHref({
|
||||
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||
siteId,
|
||||
buttonId: 'edit',
|
||||
})}
|
||||
icon="pencil"
|
||||
motionValues={motionValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append utm parameters to a URL to track usage of the toolbar.
|
||||
*/
|
||||
function getToolbarHref({
|
||||
href,
|
||||
siteId,
|
||||
buttonId,
|
||||
}: { href: string; siteId: string; buttonId: string }) {
|
||||
const url = new URL(href);
|
||||
url.searchParams.set('utm_source', 'content');
|
||||
url.searchParams.set('utm_medium', 'toolbar');
|
||||
url.searchParams.set('utm_campaign', siteId);
|
||||
url.searchParams.set('utm_content', buttonId);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
.svgLogo {
|
||||
--logo-fill: var(--color-neutral-100);
|
||||
--seg-A-color: #2782c4;
|
||||
--seg-B-color: #43b7f2;
|
||||
--seg-C-color: #8be2ff;
|
||||
|
||||
--T: 2s;
|
||||
shape-rendering: geometricPrecision;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
:global(html.dark) .svgLogo {
|
||||
--logo-fill: var(--color-neutral-800);
|
||||
--seg-A-color: #19537d;
|
||||
--seg-B-color: #2782c4;
|
||||
--seg-C-color: #43b7f2;
|
||||
--trace-color: #46474c;
|
||||
}
|
||||
|
||||
/* Base segment animation */
|
||||
.seg {
|
||||
opacity: 0;
|
||||
animation: segFade var(--T) ease both 0.06s, segAMove var(--T) linear,
|
||||
segALen var(--T) linear;
|
||||
animation-delay: 0.06s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.segA {
|
||||
animation-name: segFade, segAMove, segALen;
|
||||
}
|
||||
|
||||
.segB {
|
||||
animation-name: segFade, segBMove, segBLen;
|
||||
}
|
||||
|
||||
.segC {
|
||||
animation-name: segFade, segCMove, segCLen;
|
||||
}
|
||||
|
||||
.trace {
|
||||
animation: traceFill var(--T) ease both 0.06s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes traceFill {
|
||||
0% {
|
||||
fill: transparent;
|
||||
stroke:var(--trace-color);
|
||||
}
|
||||
95% {
|
||||
fill: transparent;
|
||||
stroke:var(--trace-color);
|
||||
}
|
||||
100% {
|
||||
fill: var(--logo-fill);
|
||||
stroke:none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Segment A animation */
|
||||
@keyframes segAMove {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: var(--seg-A-color);
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: -0.5;
|
||||
stroke: var(--seg-A-color);
|
||||
}
|
||||
95% {
|
||||
stroke-dashoffset: -1;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes segALen {
|
||||
0% {
|
||||
stroke-dasharray: 0.18 0.82;
|
||||
}
|
||||
95% {
|
||||
stroke-dasharray: 0.22 0.78;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment B animation */
|
||||
@keyframes segBMove {
|
||||
0% {
|
||||
stroke-dashoffset: -0.18;
|
||||
stroke: var(--seg-B-color);
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: -0.72;
|
||||
stroke: var(--seg-B-color);
|
||||
}
|
||||
95% {
|
||||
stroke-dashoffset: -1.18;
|
||||
stroke: var(--seg-B-color);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes segBLen {
|
||||
0%,
|
||||
50%,
|
||||
95% {
|
||||
stroke-dasharray: 0.2 0.8;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment C animation */
|
||||
@keyframes segCMove {
|
||||
0% {
|
||||
stroke-dashoffset: -0.38;
|
||||
stroke: var(--seg-C-color);
|
||||
}
|
||||
50% {
|
||||
stroke-dashoffset: -0.92;
|
||||
stroke: var(--seg-C-color);
|
||||
}
|
||||
95% {
|
||||
stroke-dashoffset: -1.38;
|
||||
stroke: var(--seg-C-color);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes segCLen {
|
||||
0% {
|
||||
stroke-dasharray: 0.22 0.78;
|
||||
}
|
||||
95% {
|
||||
stroke-dasharray: 0.18 0.82;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Segment fade animation - stays visible at end */
|
||||
@keyframes segFade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.seg {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
.trace {
|
||||
animation: none;
|
||||
fill: var(--logo-fill);
|
||||
stroke:none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type React from 'react';
|
||||
import styles from './AnimatedLogo.module.css';
|
||||
|
||||
export const AnimatedLogo: React.FC = () => {
|
||||
return (
|
||||
<svg
|
||||
className={styles.svgLogo}
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 28 28"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Background trace */}
|
||||
<path
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
className={styles.trace}
|
||||
/>
|
||||
{/* Three individual paths to create the 'chasing' segmented stroke effect */}
|
||||
<path
|
||||
className={tcls(styles.seg, styles.segA)}
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="0.18 0.82"
|
||||
strokeDashoffset={0}
|
||||
/>
|
||||
<path
|
||||
className={tcls(styles.seg, styles.segB)}
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="0.20 0.80"
|
||||
strokeDashoffset={-0.18}
|
||||
/>
|
||||
<path
|
||||
className={tcls(styles.seg, styles.segC)}
|
||||
d={dPath}
|
||||
transform="translate(14,14) scale(0.4)"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
pathLength={1}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="0.22 0.78"
|
||||
strokeDashoffset={-0.38}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const dPath =
|
||||
'M-5.07306 1.64898C-1.92626 3.46518 -0.352865 4.37328 1.37504 4.37477C3.10303 4.37628 4.67794 3.47098 7.82794 1.66027C7.82794 1.66027 27.9071 -9.88183 27.9071 -9.88183C28.8136 -10.4029 29.3724 -11.3687 29.3724 -12.4143C29.3724 -13.4598 28.8136 -14.4257 27.9071 -14.9467C27.9071 -14.9467 7.82063 -26.4931 7.82063 -26.4931C4.67414 -28.3018 3.10083 -29.2062 1.37453 -29.2055C-0.351665 -29.2048 -1.92427 -28.2992 -5.06947 -26.488C-5.06947 -26.488 -22.3372 -16.5441 -22.3372 -16.5441C-22.4651 -16.4704 -22.5291 -16.4336 -22.5888 -16.3986C-28.4872 -12.9457 -32.1315 -6.64173 -32.1802 0.192975C-32.1807 0.262075 -32.1807 0.335975 -32.1807 0.483575C-32.1807 0.631075 -32.1807 0.704875 -32.1802 0.773875C-32.1316 7.60087 -28.4955 13.899 -22.6075 17.3547C-22.548 17.3897 -22.4841 17.4266 -22.3564 17.5003C-22.3564 17.5003 -11.5399 23.7454 -11.5399 23.7454C-5.23716 27.3844 -2.08587 29.2039 1.37484 29.2051C4.83554 29.2062 7.98813 27.3889 14.2932 23.7541C14.2932 23.7541 25.7115 17.1718 25.7115 17.1718C28.8686 15.3518 30.4471 14.4418 31.3139 12.9416C32.1807 11.4414 32.1807 9.61938 32.1807 5.97517C32.1807 5.97517 32.1807 -1.06463 32.1807 -1.06463C32.1807 -2.07553 31.6332 -3.00723 30.75 -3.49913C29.8953 -3.97513 28.8536 -3.96802 28.0054 -3.48053C28.0054 -3.48053 4.59224 9.97808 4.59224 9.97808C3.02144 10.8811 2.23593 11.3326 1.37404 11.3329C0.512135 11.3331 -0.273565 10.8821 -1.84506 9.98007C-1.84506 9.98007 -17.6916 0.883775 -17.6916 0.883775C-18.4854 0.428075 -18.8823 0.200275 -19.2011 0.159175C-19.9279 0.065375 -20.6267 0.472475 -20.9036 1.15108C-21.025 1.44858 -21.0225 1.90628 -21.0176 2.82148C-21.014 3.49528 -21.0122 3.83218 -20.9492 4.14208C-20.8082 4.83607 -20.4431 5.46448 -19.91 5.93068C-19.672 6.13888 -19.3802 6.30727 -18.7966 6.64407C-18.7966 6.64407 -1.85397 16.4227 -1.85397 16.4227C-0.278465 17.332 0.509335 17.7867 1.37434 17.7869C2.23934 17.7872 3.02734 17.3329 4.60333 16.4245C4.60333 16.4245 25.3699 4.45377 25.3699 4.45377C25.9083 4.14348 26.1774 3.98828 26.3792 4.10488C26.5811 4.22148 26.5811 4.53218 26.5811 5.15357C26.5811 5.15357 26.5811 8.34667 26.5811 8.34667C26.5811 9.25768 26.5811 9.71317 26.3643 10.0883C26.1476 10.4634 25.753 10.6908 24.9637 11.1458C24.9637 11.1458 7.83524 21.0193 7.83524 21.0193C4.68194 22.837 3.10533 23.7458 1.37463 23.7451C-0.356065 23.7443 -1.93186 22.834 -5.08347 21.0134C-5.08347 21.0134 -21.1086 11.7563 -21.1086 11.7563C-21.1595 11.7269 -21.1849 11.7122 -21.2087 11.6984C-24.5687 9.73487 -26.642 6.14287 -26.6614 2.25128C-26.6616 2.22378 -26.6616 2.19438 -26.6616 2.13567C-26.6616 2.13567 -26.6616 -0.795425 -26.6616 -0.795425C-26.6616 -2.94372 -25.5174 -4.92953 -23.6587 -6.00682C-22.0163 -6.95883 -19.9905 -6.96072 -18.3463 -6.01183C-18.3463 -6.01183 -5.07306 1.64898 -5.07306 1.64898Z';
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
|
||||
import { useCheckForContentUpdate } from '@/components/AutoRefreshContent';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { ToolbarButton } from './Toolbar';
|
||||
|
||||
// We don't show the button if the content has been updated 30s ago or less.
|
||||
const minInterval = 1000 * 30; // 5 minutes
|
||||
|
||||
/**
|
||||
* Button to refresh the page if the content has been updated.
|
||||
*/
|
||||
export function RefreshChangeRequestButton(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
updatedAt: number;
|
||||
}) {
|
||||
const { updatedAt } = props;
|
||||
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const checkForUpdates = useCheckForContentUpdate(props);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkForUpdates();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setVisible(false);
|
||||
}
|
||||
}, [checkForUpdates]);
|
||||
|
||||
// Show the button if the content has been updated more than 30s ago.
|
||||
React.useEffect(() => {
|
||||
if (updatedAt < Date.now() - minInterval) {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [updatedAt]);
|
||||
|
||||
// 30sec after being hidden, we show the button again
|
||||
React.useEffect(() => {
|
||||
if (!visible) {
|
||||
const timeout = setTimeout(() => {
|
||||
setVisible(true);
|
||||
}, minInterval);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Refresh"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
<Icon icon="rotate" className={tcls('size-4', loading ? 'animate-spin' : null)} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
|
||||
import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
|
||||
|
||||
/**
|
||||
* Button to refresh the page if the content has been updated.
|
||||
*/
|
||||
export function RefreshContentButton(props: {
|
||||
refreshForUpdates: () => Promise<void>;
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
}) {
|
||||
const { refreshForUpdates, motionValues } = props;
|
||||
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await refreshForUpdates();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refreshForUpdates]);
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Refresh for latest changes"
|
||||
onClick={(event) => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
refresh();
|
||||
}}
|
||||
disabled={loading}
|
||||
motionValues={motionValues}
|
||||
icon="rotate"
|
||||
iconClassName={loading ? 'animate-spin' : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import type * as React from 'react';
|
||||
import { AnimatePresence, type MotionValue, motion, useReducedMotion } from 'motion/react';
|
||||
import React from 'react';
|
||||
import { AnimatedLogo } from './AnimatedLogo';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { Tooltip } from '../primitives';
|
||||
import { getCopyVariants, minifyButtonAnimation, toolbarEasings } from './transitions';
|
||||
import { useMagnificationEffect } from './useMagnificationEffect';
|
||||
|
||||
const DURATION_LOGO_APPEARANCE = 2000;
|
||||
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
||||
|
||||
export function Toolbar(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
const [minified, setMinified] = React.useState(true);
|
||||
const [showToolbarControls, setShowToolbarControls] = React.useState(false);
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
|
||||
// Wait for page to be ready, then show the toolbar
|
||||
React.useEffect(() => {
|
||||
const handleLoad = () => {
|
||||
setIsReady(true);
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
handleLoad();
|
||||
} else {
|
||||
window.addEventListener('load', handleLoad);
|
||||
return () => window.removeEventListener('load', handleLoad);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// After toolbar appears, wait then show the full content
|
||||
React.useEffect(() => {
|
||||
if (isReady) {
|
||||
const expandAfterTimeout = setTimeout(() => {
|
||||
setMinified(false);
|
||||
}, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT);
|
||||
|
||||
return () => clearTimeout(expandAfterTimeout);
|
||||
}
|
||||
}, [isReady]);
|
||||
|
||||
// Don't render anything until page is ready
|
||||
if (!isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'gap-4',
|
||||
'text-sm',
|
||||
'px-4',
|
||||
'py-1',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12'
|
||||
)}
|
||||
<motion.div
|
||||
onMouseEnter={() => setShowToolbarControls(true)}
|
||||
onMouseLeave={() => setShowToolbarControls(false)}
|
||||
className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
if (minified) {
|
||||
setMinified((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'border-tint-1/3',
|
||||
'backdrop-blur-sm',
|
||||
'origin-center',
|
||||
'bg-[linear-gradient(110deg,rgba(20,23,28,0.90)_0%,rgba(20,23,28,0.80)_100%)]',
|
||||
'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]'
|
||||
)}
|
||||
initial={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
boxShadow: minified
|
||||
? '0 4px 40px 8px rgba(0, 0, 0, .2), 0 0 0 .5px rgba(0, 0, 0, .4), inset 0 .5px 0 0 hsla(0, 0%, 100%, .15)'
|
||||
: '0 4px 40px 8px rgba(0, 0, 0, .4), 0 0 0 .5px rgba(0, 0, 0, .8), inset 0 .5px 0 0 hsla(0, 0%, 100%, .3)',
|
||||
}}
|
||||
style={{
|
||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||
}}
|
||||
>
|
||||
{/* Logo with stroke segments animation in blue-tints */}
|
||||
<motion.div layout>
|
||||
<AnimatedLogo />
|
||||
</motion.div>
|
||||
|
||||
{!minified ? props.children : null}
|
||||
|
||||
{!minified && showToolbarControls && <MinifyButton setMinified={setMinified} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarBody(props: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-col gap-1">{props.children}</div>;
|
||||
return <div className="flex flex-col items-start px-3">{props.children}</div>;
|
||||
}
|
||||
|
||||
export function ToolbarButtonGroups(props: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-row gap-2">{props.children}</div>;
|
||||
}
|
||||
export function ToolbarButtonGroup(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const buttonChildren = React.Children.toArray(children).filter((child) => !!child);
|
||||
const { buttonMotionValues } = useMagnificationEffect({
|
||||
childrenCount: buttonChildren.length,
|
||||
containerRef,
|
||||
});
|
||||
|
||||
export function ToolbarButton(props: React.HTMLProps<HTMLAnchorElement>) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-col',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'size-11',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1', // Equal to text-tint-strong in dark mode
|
||||
'dark:text-tint-12', // Equal to text-tint-strong in dark mode
|
||||
'hover:bg-tint-12',
|
||||
'dark:hover:bg-tint-1',
|
||||
'hover:shadow-lg',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
variants={toolbarEasings.parent}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="flex items-center gap-1 overflow-visible pr-2 pl-4"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
{buttonChildren.map((child, index) => {
|
||||
const motionValues = buttonMotionValues[index];
|
||||
const childEl = child as React.ReactElement;
|
||||
return React.cloneElement(childEl, {
|
||||
key: index,
|
||||
motionValues,
|
||||
});
|
||||
})}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToolbarButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement>, 'title'> {
|
||||
motionValues?: {
|
||||
scale: MotionValue<number>;
|
||||
x: MotionValue<number>;
|
||||
};
|
||||
icon: IconName;
|
||||
iconClassName?: string;
|
||||
title?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ToolbarButton(props: ToolbarButtonProps) {
|
||||
const { title, disabled, motionValues, className, style, href, onClick, icon, iconClassName } =
|
||||
props;
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<motion.div variants={toolbarEasings.staggeringChild}>
|
||||
<Tooltip label={title}>
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
...style,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
className,
|
||||
'flex',
|
||||
'relative',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'border-neutral-500',
|
||||
'outline-neutral-800',
|
||||
'outline-1',
|
||||
'border',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
'cursor-pointer',
|
||||
'transition-colors',
|
||||
'size-8',
|
||||
'bg-tint-1/3',
|
||||
'hover:bg-tint-1/4',
|
||||
'dark:bg-tint-3',
|
||||
'dark:hover:bg-tint-1',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'shadow-1xs'
|
||||
)}
|
||||
>
|
||||
<Icon icon={icon} className={tcls('size-4', iconClassName)} />
|
||||
</motion.a>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSeparator() {
|
||||
return <div className="h-5 w-px bg-tint-1/3" />;
|
||||
}
|
||||
|
||||
export function ToolbarTitle(props: { prefix: string; suffix: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs ">
|
||||
<ToolbarTitlePrefix title={props.prefix} />
|
||||
<ToolbarTitleSuffix title={props.suffix} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarTitlePrefix(props: { title: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(0)}
|
||||
className="font-light text-neutral-7 dark:text-neutral-3"
|
||||
>
|
||||
{props.title}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarTitleSuffix(props: { title: string }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="max-w-[24ch] truncate font-semibold text-neutral-3 dark:text-neutral-2"
|
||||
>
|
||||
{props.title}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="text-neutral-7 text-xxs dark:text-neutral-2"
|
||||
>
|
||||
{props.subtitle}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
function MinifyButton(props: { setMinified: (minified: boolean) => void }) {
|
||||
return (
|
||||
<Tooltip label="Minify">
|
||||
<motion.div
|
||||
{...minifyButtonAnimation}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
props.setMinified(true);
|
||||
}}
|
||||
className={tcls(
|
||||
'-top-2 -right-4 absolute flex size-4 cursor-pointer items-center justify-center rounded-full border',
|
||||
'border-neutral-500 bg-neutral-700 hover:border-neutral-400 hover:bg-neutral-600',
|
||||
'dark:border-neutral-400 dark:bg-neutral-200 dark:hover:border-neutral-200 dark:hover:bg-neutral-100'
|
||||
)}
|
||||
>
|
||||
<Icon icon="minus" className="size-2 text-neutral-1 dark:text-neutral-9" />
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './AdminToolbar';
|
||||
export * from './AdminToolbarClient';
|
||||
export * from './IframeWrapper';
|
||||
export * from './Toolbar';
|
||||
export * from './transitions';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Transition } from 'motion/react';
|
||||
|
||||
// Slight bounce but minimal overshoot esp. at the minimized state.
|
||||
const spring = {
|
||||
type: 'spring' as const,
|
||||
stiffness: 130,
|
||||
damping: 19,
|
||||
mass: 1,
|
||||
};
|
||||
|
||||
const parent = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
delayChildren: 0.4,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const staggeringChild = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.7,
|
||||
},
|
||||
show: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
type: 'spring',
|
||||
} as Transition,
|
||||
},
|
||||
};
|
||||
|
||||
export const minifyButtonAnimation = {
|
||||
initial: {
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
},
|
||||
animate: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: {
|
||||
scale: 0.5,
|
||||
opacity: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const getCopyVariants = (position: number) => {
|
||||
return {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
x: -10,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
},
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
delay: position / 10 + 0.3,
|
||||
} as Transition,
|
||||
};
|
||||
};
|
||||
|
||||
export const toolbarEasings = {
|
||||
spring,
|
||||
parent,
|
||||
staggeringChild,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Minimal types containing only the fields needed for AdminToolbar to restrict what gets serialized
|
||||
*/
|
||||
|
||||
export type MinimalChangeRequest = {
|
||||
id: string;
|
||||
number: number;
|
||||
subject: string | null;
|
||||
revision: string;
|
||||
updatedAt: string;
|
||||
createdBy: {
|
||||
displayName: string;
|
||||
};
|
||||
urls: {
|
||||
app: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MinimalRevision = {
|
||||
createdAt: string;
|
||||
urls: {
|
||||
app: string;
|
||||
};
|
||||
git?: {
|
||||
url: string | undefined;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type MinimalSpace = {
|
||||
id: string;
|
||||
revision: string;
|
||||
urls: {
|
||||
app: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MinimalSite = {
|
||||
id: string;
|
||||
title: string;
|
||||
urls: {
|
||||
app: string;
|
||||
published: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminToolbarContext = {
|
||||
organizationId: string;
|
||||
revisionId: string;
|
||||
space: MinimalSpace;
|
||||
changeRequest: MinimalChangeRequest | null;
|
||||
revision: MinimalRevision;
|
||||
site: MinimalSite;
|
||||
};
|
||||
|
||||
export interface AdminToolbarClientProps {
|
||||
context: AdminToolbarContext;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { type MotionValue, motionValue } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonMotionValues {
|
||||
scale: MotionValue<number>;
|
||||
x: MotionValue<number>;
|
||||
}
|
||||
|
||||
interface MagnificationConfig {
|
||||
/** Size of each button in pixels - used for spacing calculations */
|
||||
buttonSize?: number;
|
||||
/** Maximum scale factor for buttons when mouse is directly over them */
|
||||
maxScale?: number;
|
||||
/** Distance in pixels from mouse where buttons start scaling */
|
||||
influenceRadius?: number;
|
||||
/** Multiplier for spacing between buttons - higher values create more space */
|
||||
spacingMultiplier?: number;
|
||||
/** Controls scaling curve - higher values make scaling more dramatic near mouse */
|
||||
scaleExponent?: number;
|
||||
/** Padding around container for mouse detection in pixels */
|
||||
padding?: number;
|
||||
}
|
||||
|
||||
const defaultConfig: Required<MagnificationConfig> = {
|
||||
buttonSize: 32, // Standard button size for 32px (size-8) buttons
|
||||
maxScale: 1.3, // 30% scale increase - noticeable but not overwhelming
|
||||
influenceRadius: 80, // ~2.5 button widths of influence
|
||||
spacingMultiplier: 1.3, // Creates proportional spacing to scale increase
|
||||
scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling
|
||||
padding: 10, // Small buffer zone around container edges
|
||||
};
|
||||
|
||||
// Helper functions for cleaner code
|
||||
const createMotionValues = (count: number): ButtonMotionValues[] =>
|
||||
Array.from({ length: count }, () => ({
|
||||
scale: motionValue(1),
|
||||
x: motionValue(0),
|
||||
}));
|
||||
|
||||
const resetMotionValues = (motionValues: ButtonMotionValues[]) => {
|
||||
motionValues.forEach(({ scale, x }) => {
|
||||
scale.set(1);
|
||||
x.set(0);
|
||||
});
|
||||
};
|
||||
|
||||
const captureButtonPositions = (buttons: HTMLElement[]) => {
|
||||
// Reset transforms to get original positions
|
||||
buttons.forEach((button) => (button.style.transform = ''));
|
||||
return buttons.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return { left: rect.left, width: rect.width };
|
||||
});
|
||||
};
|
||||
|
||||
const calculateScale = (
|
||||
mouseX: number,
|
||||
buttonCenterX: number,
|
||||
containerRect: DOMRect,
|
||||
config: Required<MagnificationConfig>
|
||||
) => {
|
||||
const distance = Math.abs(mouseX - buttonCenterX);
|
||||
|
||||
if (distance > config.influenceRadius) return 1;
|
||||
|
||||
// Edge influence to reduce scaling near container edges
|
||||
const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX);
|
||||
const edgeInfluence = Math.min(distanceFromEdge / 20, 1);
|
||||
|
||||
// Exponential scaling curve
|
||||
const progress = distance / config.influenceRadius;
|
||||
const exponentialProgress = (1 - progress) ** config.scaleExponent;
|
||||
const baseScale = 1 + (config.maxScale - 1) * exponentialProgress;
|
||||
|
||||
return 1 + (baseScale - 1) * edgeInfluence;
|
||||
};
|
||||
|
||||
const calculateSpacing = (
|
||||
buttonIndex: number,
|
||||
buttonEffects: Array<{ scale: number; translateX: number }>,
|
||||
positions: Array<{ left: number; width: number }>,
|
||||
config: Required<MagnificationConfig>
|
||||
) => {
|
||||
const currentPos = positions[buttonIndex];
|
||||
if (!currentPos) return 0;
|
||||
|
||||
const buttonCenterX = currentPos.left + currentPos.width / 2;
|
||||
let leftPush = 0;
|
||||
let rightPush = 0;
|
||||
|
||||
buttonEffects.forEach((otherEffect, otherIndex) => {
|
||||
if (otherIndex === buttonIndex) return;
|
||||
|
||||
const otherPos = positions[otherIndex];
|
||||
if (!otherPos) return;
|
||||
|
||||
const otherCenterX = otherPos.left + otherPos.width / 2;
|
||||
const distanceBetween = Math.abs(buttonCenterX - otherCenterX);
|
||||
|
||||
// Only apply influence if close enough
|
||||
if (distanceBetween < config.influenceRadius * 1.5) {
|
||||
const influenceStrength = Math.max(
|
||||
0,
|
||||
1 - distanceBetween / (config.influenceRadius * 1.5)
|
||||
);
|
||||
const extraSpace = (otherEffect.scale - 1) * config.buttonSize;
|
||||
const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength;
|
||||
|
||||
if (otherCenterX < buttonCenterX) {
|
||||
rightPush += pushAmount;
|
||||
} else {
|
||||
leftPush += pushAmount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rightPush - leftPush;
|
||||
};
|
||||
|
||||
export function useMagnificationEffect(props: {
|
||||
childrenCount: number;
|
||||
containerRef: React.RefObject<HTMLElement>;
|
||||
config?: MagnificationConfig;
|
||||
}) {
|
||||
const { childrenCount, containerRef, config } = props;
|
||||
const [buttonMotionValues, setButtonMotionValues] = React.useState<ButtonMotionValues[]>([]);
|
||||
const originalPositionsRef = React.useRef<Array<{ left: number; width: number }>>([]);
|
||||
|
||||
const finalConfig = { ...defaultConfig, ...config };
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[];
|
||||
|
||||
if (buttons.length !== childrenCount) {
|
||||
console.error(
|
||||
`Button count (${buttons.length}) does not match children count (${childrenCount})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize motion values if button count changed
|
||||
if (buttonMotionValues.length !== buttons.length) {
|
||||
setButtonMotionValues(createMotionValues(buttons.length));
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const buttons = Array.from(
|
||||
container.querySelectorAll('.toolbar-button')
|
||||
) as HTMLElement[];
|
||||
|
||||
// Capture positions if needed
|
||||
if (originalPositionsRef.current.length !== buttons.length) {
|
||||
originalPositionsRef.current = captureButtonPositions(buttons);
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const { clientX: mouseX, clientY: mouseY } = event;
|
||||
|
||||
// Check if mouse is in range
|
||||
const isInRange =
|
||||
mouseX >= containerRect.left - finalConfig.padding &&
|
||||
mouseX <= containerRect.right + finalConfig.padding &&
|
||||
mouseY >= containerRect.top - finalConfig.padding &&
|
||||
mouseY <= containerRect.bottom + finalConfig.padding;
|
||||
|
||||
if (!isInRange) {
|
||||
resetMotionValues(buttonMotionValues);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate scales for all buttons
|
||||
const buttonEffects = buttons.map((_, index) => {
|
||||
const pos = originalPositionsRef.current[index];
|
||||
if (!pos) return { scale: 1, translateX: 0 };
|
||||
|
||||
const buttonCenterX = pos.left + pos.width / 2;
|
||||
const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig);
|
||||
return { scale, translateX: 0 };
|
||||
});
|
||||
|
||||
// Calculate spacing for all buttons
|
||||
buttonEffects.forEach((effect, index) => {
|
||||
effect.translateX = calculateSpacing(
|
||||
index,
|
||||
buttonEffects,
|
||||
originalPositionsRef.current,
|
||||
finalConfig
|
||||
);
|
||||
});
|
||||
|
||||
// Update motion values
|
||||
buttonEffects.forEach((effect, index) => {
|
||||
const motionValue = buttonMotionValues[index];
|
||||
if (motionValue) {
|
||||
motionValue.scale.set(effect.scale);
|
||||
motionValue.x.set(effect.translateX);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => resetMotionValues(buttonMotionValues);
|
||||
|
||||
container.addEventListener('mousemove', handleMouseMove);
|
||||
container.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousemove', handleMouseMove);
|
||||
container.removeEventListener('mouseleave', handleMouseLeave);
|
||||
};
|
||||
}, [
|
||||
finalConfig.buttonSize,
|
||||
finalConfig.maxScale,
|
||||
finalConfig.influenceRadius,
|
||||
finalConfig.spacingMultiplier,
|
||||
finalConfig.scaleExponent,
|
||||
finalConfig.padding,
|
||||
containerRef,
|
||||
buttonMotionValues,
|
||||
childrenCount,
|
||||
]);
|
||||
|
||||
return { buttonMotionValues };
|
||||
}
|
||||
@@ -1,28 +1,43 @@
|
||||
'use server';
|
||||
|
||||
import { getDataOrNull } from '@/lib/data';
|
||||
import { getSiteURLDataFromMiddleware } from '@/lib/middleware';
|
||||
import { getServerActionBaseContext } from '@/lib/server-actions';
|
||||
import { traceErrorOnly } from '@/lib/tracing';
|
||||
|
||||
/**
|
||||
* Return true if a change-request has been updated.
|
||||
* Return true if the content has been updated.
|
||||
* It compares the revision ID displayed on the client side with the latest revision ID from the server/cache.
|
||||
*/
|
||||
export async function hasContentBeenUpdated(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
revisionId: string;
|
||||
}) {
|
||||
return traceErrorOnly('AutoRefreshContent.hasContentBeenUpdated', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const changeRequest = await getDataOrNull(
|
||||
context.dataFetcher.getChangeRequest({
|
||||
spaceId: props.spaceId,
|
||||
changeRequestId: props.changeRequestId,
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
if (siteURLData.changeRequest) {
|
||||
const changeRequest = await getDataOrNull(
|
||||
context.dataFetcher.getChangeRequest({
|
||||
spaceId: siteURLData.space,
|
||||
changeRequestId: siteURLData.changeRequest,
|
||||
})
|
||||
);
|
||||
if (!changeRequest) {
|
||||
return false;
|
||||
}
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
}
|
||||
|
||||
const space = await getDataOrNull(
|
||||
context.dataFetcher.getSpace({
|
||||
spaceId: siteURLData.space,
|
||||
shareKey: siteURLData.shareKey,
|
||||
})
|
||||
);
|
||||
if (!changeRequest) {
|
||||
if (!space) {
|
||||
return false;
|
||||
}
|
||||
return changeRequest.revision !== props.revisionId;
|
||||
return space.revision !== props.revisionId;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,20 +5,71 @@ import React from 'react';
|
||||
import { hasContentBeenUpdated } from './server-actions';
|
||||
|
||||
/**
|
||||
* Return a callback to check if a change request has been updated and to refresh the page if it has.
|
||||
* Return a callback to check if a content has been updated and to refresh the page if it has.
|
||||
*/
|
||||
export function useCheckForContentUpdate(props: {
|
||||
spaceId: string;
|
||||
changeRequestId: string;
|
||||
/**
|
||||
* ID of the revision of the content currently being displayed.
|
||||
*/
|
||||
revisionId: string;
|
||||
/**
|
||||
* How long after page load to check for updates.
|
||||
* @default 5min
|
||||
*/
|
||||
checkAfterLoad?: number;
|
||||
}) {
|
||||
const { spaceId, changeRequestId, revisionId } = props;
|
||||
|
||||
return React.useCallback(async () => {
|
||||
const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId });
|
||||
const { revisionId, checkAfterLoad = 5 * 60 * 1000 } = props;
|
||||
const [updated, setUpdated] = React.useState(false);
|
||||
|
||||
const checkForUpdates = React.useCallback(async () => {
|
||||
if (updated) {
|
||||
// No need to check for updates if we already know the content has been updated
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await hasContentBeenUpdated({ revisionId });
|
||||
setUpdated(result);
|
||||
return result;
|
||||
}, [revisionId, updated]);
|
||||
|
||||
const refreshForUpdates = React.useCallback(async () => {
|
||||
if (await checkForUpdates()) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [spaceId, changeRequestId, revisionId]);
|
||||
}, [checkForUpdates]);
|
||||
|
||||
// Check for updates after the page has loaded
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// We check only if the tab is visible; otherwise we'll check when the tab is focus again
|
||||
checkForUpdates();
|
||||
}
|
||||
}, checkAfterLoad);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [checkForUpdates, checkAfterLoad]);
|
||||
|
||||
// Check for updates when the tab is becoming visible
|
||||
React.useEffect(() => {
|
||||
if (updated) {
|
||||
// No need to check for updates if we already know the content has been updated
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdates();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', callback);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', callback);
|
||||
};
|
||||
}, [checkForUpdates, updated]);
|
||||
|
||||
return { refreshForUpdates, updated };
|
||||
}
|
||||
|
||||
@@ -120,9 +120,28 @@ export function Header(props: {
|
||||
>
|
||||
<SearchContainer
|
||||
style={customization.styling.search}
|
||||
isMultiVariants={siteSpaces.length > 1}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.filter(
|
||||
(s) => s.space.language === siteSpace.space.language
|
||||
).length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={!!sections}
|
||||
section={
|
||||
sections
|
||||
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
|
||||
encodeClientSiteSections(context, sections).current
|
||||
: undefined
|
||||
}
|
||||
spaceTitle={siteSpace.title}
|
||||
siteSpaceId={siteSpace.id}
|
||||
siteSpaceIds={siteSpaces
|
||||
.filter((s) => s.space.language === siteSpace.space.language)
|
||||
.map((s) => s.id)}
|
||||
viewport={!withTopHeader ? 'mobile' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
||||
import { getAllBrowserCookiesMap } from '@/lib/browser';
|
||||
import { type CurrentContentContext, useCurrentContent } from '../hooks';
|
||||
import { getSession } from './sessions';
|
||||
import { getVisitorId } from './visitorId';
|
||||
import { type SessionResponse, useVisitorSession } from './visitorId';
|
||||
|
||||
export type InsightsEventName = api.SiteInsightsEvent['type'];
|
||||
|
||||
@@ -55,12 +55,6 @@ interface InsightsProviderProps {
|
||||
/** If true, the events will be sent to the server. */
|
||||
enabled: boolean;
|
||||
|
||||
/** If true, the visitor cookie tracking will be used */
|
||||
visitorCookieTrackingEnabled: boolean;
|
||||
|
||||
/** The application URL. */
|
||||
appURL: string;
|
||||
|
||||
/** The url of the endpoint to send events to */
|
||||
eventUrl: string;
|
||||
|
||||
@@ -72,10 +66,10 @@ interface InsightsProviderProps {
|
||||
* Wrap the content of the app with the InsightsProvider to track events.
|
||||
*/
|
||||
export function InsightsProvider(props: InsightsProviderProps) {
|
||||
const { enabled, children, visitorCookieTrackingEnabled, eventUrl, appURL } = props;
|
||||
const { enabled, children, eventUrl } = props;
|
||||
|
||||
const visitorSession = useVisitorSession();
|
||||
const currentContent = useCurrentContent();
|
||||
const visitorIdRef = React.useRef<string | null>(null);
|
||||
const eventsRef = React.useRef<{
|
||||
[pathname: string]:
|
||||
| {
|
||||
@@ -92,9 +86,8 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
*/
|
||||
const flushEventsSync = useEventCallback(() => {
|
||||
const session = getSession();
|
||||
const visitorId = visitorIdRef.current;
|
||||
if (!visitorId) {
|
||||
throw new Error('Visitor ID should be set before flushing events');
|
||||
if (!visitorSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allEvents: api.SiteInsightsEvent[] = [];
|
||||
@@ -114,7 +107,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
events: eventsForPathname.events,
|
||||
context: currentContent,
|
||||
pageContext: eventsForPathname.pageContext,
|
||||
visitorId,
|
||||
visitorSession,
|
||||
sessionId: session.id,
|
||||
})
|
||||
);
|
||||
@@ -126,25 +119,25 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
};
|
||||
}
|
||||
|
||||
if (allEvents.length > 0) {
|
||||
if (enabled) {
|
||||
sendEvents({
|
||||
eventUrl,
|
||||
events: allEvents,
|
||||
});
|
||||
} else {
|
||||
}
|
||||
if (allEvents.length > 0 && enabled) {
|
||||
sendEvents({
|
||||
eventUrl,
|
||||
events: allEvents,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const flushBatchedEvents = useDebounceCallback(async () => {
|
||||
const visitorId =
|
||||
visitorIdRef.current ?? (await getVisitorId(appURL, visitorCookieTrackingEnabled));
|
||||
visitorIdRef.current = visitorId;
|
||||
|
||||
const flushBatchedEvents = useDebounceCallback(() => {
|
||||
flushEventsSync();
|
||||
}, 1500);
|
||||
|
||||
// Flush pending events once the visitor session has been fetched
|
||||
React.useEffect(() => {
|
||||
if (visitorSession) {
|
||||
flushEventsSync();
|
||||
}
|
||||
}, [visitorSession, flushEventsSync]);
|
||||
|
||||
const trackEvent: TrackEventCallback = useEventCallback(
|
||||
(
|
||||
event: TrackEventInput<InsightsEventName>,
|
||||
@@ -169,7 +162,7 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
if (eventsRef.current[pathname].pageContext !== undefined) {
|
||||
// If the pageId is set, we know that the page_view event has been tracked
|
||||
// and we can flush the events
|
||||
if (options?.immediate && visitorIdRef.current) {
|
||||
if (options?.immediate) {
|
||||
flushBatchedEvents.cancel();
|
||||
flushEventsSync();
|
||||
} else {
|
||||
@@ -180,18 +173,14 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the visitor ID and store it in a ref.
|
||||
* When the page is unloaded, flush all events.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
getVisitorId(appURL, visitorCookieTrackingEnabled).then((visitorId) => {
|
||||
visitorIdRef.current = visitorId;
|
||||
// When the page is unloaded, flush all events, but only if the visitor ID is set
|
||||
window.addEventListener('beforeunload', flushEventsSync);
|
||||
});
|
||||
window.addEventListener('beforeunload', flushEventsSync);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', flushEventsSync);
|
||||
};
|
||||
}, [flushEventsSync, visitorCookieTrackingEnabled, appURL]);
|
||||
}, [flushEventsSync]);
|
||||
|
||||
return (
|
||||
<InsightsContext.Provider value={trackEvent}>
|
||||
@@ -245,12 +234,12 @@ function transformEvents(input: {
|
||||
events: TrackEventInput<InsightsEventName>[];
|
||||
context: CurrentContentContext;
|
||||
pageContext: InsightsEventPageContext;
|
||||
visitorId: string;
|
||||
visitorSession: SessionResponse;
|
||||
sessionId: string;
|
||||
}): api.SiteInsightsEvent[] {
|
||||
const session: api.SiteInsightsEventSession = {
|
||||
sessionId: input.sessionId,
|
||||
visitorId: input.visitorId,
|
||||
visitorId: input.visitorSession.deviceId,
|
||||
userAgent: window.navigator.userAgent,
|
||||
language: window.navigator.language,
|
||||
cookies: getAllBrowserCookiesMap(),
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getBrowserCookie } from '@/lib/browser';
|
||||
|
||||
import { isCookiesTrackingDisabled } from './cookies';
|
||||
import { generateRandomId } from './utils';
|
||||
|
||||
const VISITORID_COOKIE = '__session';
|
||||
|
||||
let visitorId: string | null = null;
|
||||
let pendingVisitorId: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* Return the current visitor identifier.
|
||||
*/
|
||||
export async function getVisitorId(
|
||||
appURL: string,
|
||||
visitorCookieTrackingEnabled: boolean
|
||||
): Promise<string> {
|
||||
if (!visitorId) {
|
||||
if (!pendingVisitorId) {
|
||||
pendingVisitorId = fetchVisitorID(appURL, visitorCookieTrackingEnabled).finally(() => {
|
||||
pendingVisitorId = null;
|
||||
});
|
||||
}
|
||||
|
||||
visitorId = await pendingVisitorId;
|
||||
}
|
||||
|
||||
return visitorId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
|
||||
*/
|
||||
async function fetchVisitorID(
|
||||
appURL: string,
|
||||
visitorCookieTrackingEnabled: boolean
|
||||
): Promise<string> {
|
||||
const withoutCookies = isCookiesTrackingDisabled();
|
||||
|
||||
if (withoutCookies || !visitorCookieTrackingEnabled) {
|
||||
return generateRandomId();
|
||||
}
|
||||
|
||||
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
|
||||
|
||||
if (existingTrackingCookie) {
|
||||
// If the cookie already exists, we'll just use that. Avoids a server request.
|
||||
return existingTrackingCookie;
|
||||
}
|
||||
// No tracking deviceId set, we'll need to consolidate with the server.
|
||||
const proposed = generateRandomId();
|
||||
|
||||
const url = new URL(appURL);
|
||||
url.pathname = '/__session/2/';
|
||||
url.searchParams.set('proposed', proposed);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET', // Use GET to play nicely with SameSite cookies.
|
||||
credentials: 'include', // Make sure to send/receive cookies.
|
||||
cache: 'no-cache',
|
||||
mode: 'cors', // Need to use cors as we are on a different domain.
|
||||
});
|
||||
|
||||
const { deviceId } = (await resp.json()) as { deviceId: string };
|
||||
return deviceId;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch visitor session ID', error);
|
||||
return proposed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
import { getBrowserCookie } from '@/lib/browser';
|
||||
|
||||
import React from 'react';
|
||||
import { isCookiesTrackingDisabled } from './cookies';
|
||||
import { generateRandomId } from './utils';
|
||||
|
||||
const VISITORID_COOKIE = '__session';
|
||||
|
||||
type SessionVisitorResponse = {
|
||||
deviceId: string;
|
||||
userId?: undefined;
|
||||
organizationId?: undefined;
|
||||
};
|
||||
|
||||
type SessionUserResponse = SessionVisitorResponse & {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
export type SessionResponse = SessionVisitorResponse | SessionUserResponse;
|
||||
|
||||
const visitorSessionStore = createStore<{
|
||||
session: SessionResponse | null;
|
||||
pendingSession: Promise<SessionResponse> | null;
|
||||
}>(() => ({
|
||||
session: null,
|
||||
pendingSession: null,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Fetch, synchronize the visitor sesion with GitBook.
|
||||
*/
|
||||
export function VisitorSessionProvider(
|
||||
props: React.PropsWithChildren<{
|
||||
appURL: string;
|
||||
visitorCookieTrackingEnabled: boolean;
|
||||
}>
|
||||
) {
|
||||
const { appURL, visitorCookieTrackingEnabled, children } = props;
|
||||
|
||||
React.useEffect(() => {
|
||||
const state = visitorSessionStore.getState();
|
||||
if (state.pendingSession || state.session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingSession = fetchSession({ appURL, visitorCookieTrackingEnabled });
|
||||
visitorSessionStore.setState({ pendingSession, session: null });
|
||||
pendingSession.then((session) => {
|
||||
visitorSessionStore.setState({ pendingSession: null, session });
|
||||
});
|
||||
}, [appURL, visitorCookieTrackingEnabled]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the current visitor session.
|
||||
*/
|
||||
export function useVisitorSession() {
|
||||
return useStore(visitorSessionStore, (state) => state.session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Propose a visitor identifier to the GitBook.com server and get the devideId back.
|
||||
*/
|
||||
async function fetchSession({
|
||||
appURL,
|
||||
visitorCookieTrackingEnabled,
|
||||
}: {
|
||||
appURL: string;
|
||||
visitorCookieTrackingEnabled: boolean;
|
||||
}): Promise<SessionResponse> {
|
||||
const withoutCookies = isCookiesTrackingDisabled();
|
||||
|
||||
if (withoutCookies || !visitorCookieTrackingEnabled) {
|
||||
return {
|
||||
deviceId: generateRandomId(),
|
||||
};
|
||||
}
|
||||
|
||||
const { existing, proposedId } = getSessionCookie();
|
||||
|
||||
if (existing) {
|
||||
// If the cookie already exists, we'll just use that. Avoids a server request.
|
||||
return existing;
|
||||
}
|
||||
|
||||
const url = new URL(appURL);
|
||||
url.pathname = '/__session/2/';
|
||||
url.searchParams.set('proposed', proposedId);
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET', // Use GET to play nicely with SameSite cookies.
|
||||
credentials: 'include', // Make sure to send/receive cookies.
|
||||
cache: 'no-cache',
|
||||
mode: 'cors', // Need to use cors as we are on a different domain.
|
||||
});
|
||||
|
||||
const session = (await resp.json()) as SessionResponse;
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch visitor session ID', error);
|
||||
return {
|
||||
deviceId: proposedId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionCookie(): { existing: SessionResponse | null; proposedId: string } {
|
||||
const proposed = generateRandomId();
|
||||
const value = getBrowserCookie(VISITORID_COOKIE);
|
||||
if (!(value && typeof value === 'string')) {
|
||||
return { existing: null, proposedId: proposed };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as SessionResponse;
|
||||
return { existing: parsed, proposedId: proposed };
|
||||
} catch {
|
||||
// Ignore legacy __session cookie format
|
||||
// and we'll renew it with the server using the previous one as a proposed ID
|
||||
return { existing: null, proposedId: proposed };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationSearchStyle } from '@gitbook/api';
|
||||
import { CustomizationSearchStyle, type SiteSection } from '@gitbook/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@@ -18,9 +18,27 @@ import { SearchScopeToggle } from './SearchScopeToggle';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
interface SearchContainerProps {
|
||||
/** The current site space id. */
|
||||
siteSpaceId: string;
|
||||
|
||||
/** The title of the current space. */
|
||||
spaceTitle: string;
|
||||
isMultiVariants: boolean;
|
||||
|
||||
/** The ids of all spaces in the current section. */
|
||||
siteSpaceIds: string[];
|
||||
|
||||
/** Whether there are sections on the site. */
|
||||
withSections: boolean;
|
||||
|
||||
/** The current section, displayed in search scope toggle. */
|
||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||
|
||||
/** Whether the current section has variants. */
|
||||
withVariants: boolean;
|
||||
|
||||
/** Whether any section on the site has variants. */
|
||||
withSiteVariants: boolean;
|
||||
|
||||
style: CustomizationSearchStyle;
|
||||
className?: string;
|
||||
viewport?: 'desktop' | 'mobile';
|
||||
@@ -30,7 +48,18 @@ interface SearchContainerProps {
|
||||
* Client component to render the search input and results.
|
||||
*/
|
||||
export function SearchContainer(props: SearchContainerProps) {
|
||||
const { siteSpaceId, spaceTitle, isMultiVariants, style, className, viewport } = props;
|
||||
const {
|
||||
siteSpaceId,
|
||||
spaceTitle,
|
||||
section,
|
||||
withVariants,
|
||||
withSiteVariants,
|
||||
withSections,
|
||||
style,
|
||||
className,
|
||||
viewport,
|
||||
siteSpaceIds,
|
||||
} = props;
|
||||
|
||||
const { assistants } = useAI();
|
||||
|
||||
@@ -108,7 +137,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
}
|
||||
setSearchState((prev) => ({
|
||||
ask: withAI ? (prev?.ask ?? null) : null,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
query: prev?.query ?? (withSearchAI || !withAI ? prev?.ask : null) ?? '',
|
||||
open: true,
|
||||
}));
|
||||
@@ -148,7 +177,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
setSearchState((prev) => ({
|
||||
ask: withAI && !withSearchAI ? (prev?.ask ?? null) : null, // When typing, we reset ask to get back to normal search (unless non-search assistants are defined)
|
||||
query: value,
|
||||
global: prev?.global ?? false,
|
||||
scope: prev?.scope ?? 'default',
|
||||
open: true,
|
||||
}));
|
||||
};
|
||||
@@ -168,15 +197,22 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
// Only show content if there's a query or Ask is enabled
|
||||
state?.query || withAI ? (
|
||||
<React.Suspense fallback={null}>
|
||||
{isMultiVariants && !showAsk ? (
|
||||
<SearchScopeToggle spaceTitle={spaceTitle} />
|
||||
{(withVariants || withSections) && !showAsk ? (
|
||||
<SearchScopeToggle
|
||||
section={section}
|
||||
spaceTitle={spaceTitle}
|
||||
withVariants={withVariants}
|
||||
withSiteVariants={withSiteVariants}
|
||||
withSections={withSections}
|
||||
/>
|
||||
) : null}
|
||||
{state !== null && !showAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
query={normalizedQuery}
|
||||
global={state?.global ?? false}
|
||||
scope={state?.scope ?? 'default'}
|
||||
siteSpaceId={siteSpaceId}
|
||||
siteSpaceIds={siteSpaceIds}
|
||||
/>
|
||||
) : null}
|
||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
||||
@@ -194,7 +230,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
align: 'start',
|
||||
className:
|
||||
'bg-tint-base has-[.empty]:hidden gutter-stable scroll-py-2 w-128 p-2 pr-1 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
'@container bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
onInteractOutside: (event) => {
|
||||
// Don't close if clicking on the search input itself
|
||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||
|
||||
@@ -17,9 +17,11 @@ import { SearchSectionResultItem } from './SearchSectionResultItem';
|
||||
import {
|
||||
type OrderedComputedResult,
|
||||
searchAllSiteContent,
|
||||
searchSiteSpaceContent,
|
||||
searchCurrentSiteSpaceContent,
|
||||
searchSpecificSiteSpaceContent,
|
||||
streamRecommendedQuestions,
|
||||
} from './server-actions';
|
||||
import type { SearchScope } from './useSearch';
|
||||
|
||||
export interface SearchResultsRef {
|
||||
moveUp(): void;
|
||||
@@ -50,12 +52,13 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
props: {
|
||||
children?: React.ReactNode;
|
||||
query: string;
|
||||
global: boolean;
|
||||
scope: SearchScope;
|
||||
siteSpaceId: string;
|
||||
siteSpaceIds: string[];
|
||||
},
|
||||
ref: React.Ref<SearchResultsRef>
|
||||
) {
|
||||
const { children, query, global, siteSpaceId } = props;
|
||||
const { children, query, scope, siteSpaceId, siteSpaceIds } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const trackEvent = useTrackEvent();
|
||||
@@ -133,9 +136,25 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
setResultsState((prev) => ({ results: prev.results, fetching: true }));
|
||||
let cancelled = false;
|
||||
const timeout = setTimeout(async () => {
|
||||
const results = await (global
|
||||
? searchAllSiteContent(query)
|
||||
: searchSiteSpaceContent(query));
|
||||
const results = await (() => {
|
||||
if (scope === 'all') {
|
||||
// Search all content on the site
|
||||
return searchAllSiteContent(query);
|
||||
}
|
||||
if (scope === 'default') {
|
||||
// Search the current section's variant + matched/default variant for other sections
|
||||
return searchCurrentSiteSpaceContent(query, siteSpaceId);
|
||||
}
|
||||
if (scope === 'extended') {
|
||||
// Search all variants of the current section
|
||||
return searchSpecificSiteSpaceContent(query, siteSpaceIds);
|
||||
}
|
||||
if (scope === 'current') {
|
||||
// Search only the current section's current variant
|
||||
return searchSpecificSiteSpaceContent(query, [siteSpaceId]);
|
||||
}
|
||||
throw new Error(`Unhandled search scope: ${scope}`);
|
||||
})();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -158,7 +177,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [query, global, trackEvent, withAI, siteSpaceId]);
|
||||
}, [query, scope, trackEvent, withAI, siteSpaceId, siteSpaceIds]);
|
||||
|
||||
const results: ResultType[] = React.useMemo(() => {
|
||||
if (!withAI) {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { Button } from '../primitives';
|
||||
import type { SiteSection } from '@gitbook/api';
|
||||
import { SegmentedControl, SegmentedControlItem } from '../primitives/SegmentedControl';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
/**
|
||||
* Toolbar to toggle between search modes (global or scoped to a space).
|
||||
* Only visible when the space is in a collection.
|
||||
*/
|
||||
export function SearchScopeToggle(props: { spaceTitle: string }) {
|
||||
const { spaceTitle } = props;
|
||||
export function SearchScopeToggle(props: {
|
||||
spaceTitle: string;
|
||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||
withVariants: boolean;
|
||||
withSiteVariants: boolean;
|
||||
withSections: boolean;
|
||||
}) {
|
||||
const { spaceTitle, section, withVariants, withSections, withSiteVariants } = props;
|
||||
const [state, setSearchState] = useSearch();
|
||||
const language = useLanguage();
|
||||
|
||||
@@ -16,37 +25,82 @@ export function SearchScopeToggle(props: { spaceTitle: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
className="mb-2 flex flex-row flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint-subtle p-1"
|
||||
>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="medium"
|
||||
className="shrink grow justify-center whitespace-normal"
|
||||
active={!state.global}
|
||||
label={tString(language, 'search_scope_space', spaceTitle)}
|
||||
onClick={() => {
|
||||
setSearchState({
|
||||
...state,
|
||||
global: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="blank"
|
||||
size="medium"
|
||||
className="shrink grow justify-center whitespace-normal"
|
||||
active={state.global}
|
||||
label={tString(language, 'search_scope_all')}
|
||||
onClick={() => {
|
||||
setSearchState({
|
||||
...state,
|
||||
global: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{withSections ? (
|
||||
<SegmentedControl className="animate-scale-in">
|
||||
{/* `Default` scope = current section's current variant + best match in other sections */}
|
||||
<SegmentedControlItem
|
||||
active={
|
||||
withSiteVariants
|
||||
? state.scope === 'default'
|
||||
: ['default', 'all'].includes(state.scope)
|
||||
}
|
||||
label={
|
||||
withSiteVariants
|
||||
? tString(language, 'search_scope_default')
|
||||
: tString(language, 'search_scope_all')
|
||||
}
|
||||
className={withSiteVariants ? '@max-md:basis-full' : ''}
|
||||
icon={withSiteVariants ? 'bullseye-arrow' : 'infinity'}
|
||||
onClick={() => setSearchState({ ...state, scope: 'default' })}
|
||||
/>
|
||||
|
||||
{/* `Current` scope = current section's current variant (with further variant scope selection if necessary) */}
|
||||
<SegmentedControlItem
|
||||
active={state.scope === 'current' || state.scope === 'extended'}
|
||||
icon={section?.icon ?? 'crosshairs'}
|
||||
label={tString(language, 'search_scope_current', section?.title)}
|
||||
onClick={() => setSearchState({ ...state, scope: 'current' })}
|
||||
/>
|
||||
|
||||
{/* `All` scope = all content on the site. Only visible if site has variants, otherwise it's the same as default */}
|
||||
{withSiteVariants ? (
|
||||
<SegmentedControlItem
|
||||
active={state.scope === 'all'}
|
||||
label={tString(language, 'search_scope_all')}
|
||||
icon="infinity"
|
||||
onClick={() => setSearchState({ ...state, scope: 'all' })}
|
||||
/>
|
||||
) : null}
|
||||
</SegmentedControl>
|
||||
) : null}
|
||||
{withVariants &&
|
||||
(!withSections || state.scope === 'current' || state.scope === 'extended') ? (
|
||||
<SegmentedControl className="animate-scale-in">
|
||||
{/* `Current` scope = current section's current variant. `Default` on sites without sections. */}
|
||||
<SegmentedControlItem
|
||||
size={withSections ? 'small' : 'medium'}
|
||||
active={
|
||||
withSections
|
||||
? state.scope === 'current'
|
||||
: ['default', 'current'].includes(state.scope)
|
||||
}
|
||||
className="py-1"
|
||||
label={tString(language, 'search_scope_current', spaceTitle)}
|
||||
onClick={() =>
|
||||
setSearchState({
|
||||
...state,
|
||||
scope: withSections ? 'current' : 'default',
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{/* `Extended` scope = all variants of the current section. `All` on sites without sections. */}
|
||||
<SegmentedControlItem
|
||||
size={withSections ? 'small' : 'medium'}
|
||||
active={
|
||||
withSections
|
||||
? state.scope === 'extended'
|
||||
: ['extended', 'all'].includes(state.scope)
|
||||
}
|
||||
className="py-1"
|
||||
label={tString(language, 'search_scope_extended')}
|
||||
onClick={() =>
|
||||
setSearchState({ ...state, scope: withSections ? 'extended' : 'all' })
|
||||
}
|
||||
/>
|
||||
</SegmentedControl>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,18 +81,33 @@ export async function searchAllSiteContent(query: string): Promise<OrderedComput
|
||||
/**
|
||||
* Server action to search content in a space.
|
||||
*/
|
||||
export async function searchSiteSpaceContent(query: string): Promise<OrderedComputedResult[]> {
|
||||
export async function searchCurrentSiteSpaceContent(
|
||||
query: string,
|
||||
siteSpaceId: string
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
// If we have a siteSectionId that means its a sections site use `current` mode
|
||||
// which searches in the current space + all default spaces of sections
|
||||
scope: siteURLData.siteSection
|
||||
? { mode: 'current', siteSpaceId: siteURLData.siteSpace }
|
||||
: { mode: 'specific', siteSpaceIds: [siteURLData.siteSpace] },
|
||||
scope: { mode: 'current', siteSpaceId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to search content in a specific space.
|
||||
*/
|
||||
export async function searchSpecificSiteSpaceContent(
|
||||
query: string,
|
||||
siteSpaceIds: string[]
|
||||
): Promise<OrderedComputedResult[]> {
|
||||
return traceErrorOnly('Search.searchSiteSpaceContent', async () => {
|
||||
const context = await getServerActionBaseContext();
|
||||
|
||||
return await searchSiteContent(context, {
|
||||
query,
|
||||
scope: { mode: 'specific', siteSpaceIds },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs';
|
||||
import React from 'react';
|
||||
import type { LinkProps } from '../primitives';
|
||||
|
||||
export type SearchScope =
|
||||
/** Search all content on the site */
|
||||
| 'all'
|
||||
/** Search the current section's variant + matched/default variant for other sections */
|
||||
| 'default'
|
||||
/** Search all variants of the current section */
|
||||
| 'extended'
|
||||
/** Search only the current section's current variant */
|
||||
| 'current';
|
||||
|
||||
export interface SearchState {
|
||||
// URL-backed state
|
||||
query: string | null;
|
||||
ask: string | null;
|
||||
global: boolean;
|
||||
scope: SearchScope;
|
||||
|
||||
// Local UI state
|
||||
open: boolean;
|
||||
@@ -18,7 +28,8 @@ export interface SearchState {
|
||||
const keyMap = {
|
||||
q: parseAsString,
|
||||
ask: parseAsString,
|
||||
global: parseAsBoolean,
|
||||
scope: parseAsStringLiteral(['all', 'default', 'extended', 'current']).withDefault('default'),
|
||||
global: parseAsBoolean, // Legacy support for global=true
|
||||
};
|
||||
|
||||
export type UpdateSearchState = (
|
||||
@@ -41,14 +52,21 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
history: 'replace',
|
||||
});
|
||||
|
||||
// Handle legacy ask=true format by converting it to the new format
|
||||
React.useEffect(() => {
|
||||
// Handle legacy ask=true format by converting it to the new format
|
||||
if (rawState?.ask === 'true' && rawState?.q) {
|
||||
// Convert legacy format: q=query&ask=true -> ask=query&q=null
|
||||
setRawState({
|
||||
q: null,
|
||||
ask: rawState.q,
|
||||
global: rawState.global,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle legacy global=true
|
||||
if (rawState?.global === true) {
|
||||
setRawState({
|
||||
scope: 'all',
|
||||
global: null, // Remove the legacy parameter
|
||||
});
|
||||
}
|
||||
}, [rawState, setRawState]);
|
||||
@@ -65,7 +83,7 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
return {
|
||||
query: rawState.q,
|
||||
ask: rawState.ask,
|
||||
global: !!rawState.global,
|
||||
scope: rawState.scope,
|
||||
open,
|
||||
};
|
||||
}, [rawState, open]);
|
||||
@@ -85,14 +103,14 @@ export function SearchContextProvider(props: React.PropsWithChildren): React.Rea
|
||||
|
||||
if (update === null) {
|
||||
setIsOpen(false);
|
||||
return setRawState({ q: null, ask: null, global: null });
|
||||
return setRawState({ q: null, ask: null, scope: 'default' });
|
||||
}
|
||||
|
||||
setIsOpen(update.open);
|
||||
return setRawState({
|
||||
q: update.query,
|
||||
ask: update.ask,
|
||||
global: update.global ? true : null,
|
||||
scope: update.scope,
|
||||
});
|
||||
},
|
||||
[setRawState]
|
||||
@@ -126,7 +144,7 @@ export function useSearchLink(): (
|
||||
const searchParams = new URLSearchParams();
|
||||
params.query ? searchParams.set('q', params.query) : searchParams.delete('q');
|
||||
params.ask ? searchParams.set('ask', params.ask) : searchParams.delete('ask');
|
||||
params.global ? searchParams.set('global', 'true') : searchParams.delete('global');
|
||||
params.scope ? searchParams.set('scope', params.scope) : searchParams.delete('scope');
|
||||
return {
|
||||
href: `?${searchParams.toString()}`,
|
||||
prefetch: false,
|
||||
@@ -137,7 +155,7 @@ export function useSearchLink(): (
|
||||
...prev,
|
||||
query: params.query !== undefined ? params.query : null,
|
||||
ask: params.ask !== undefined ? params.ask : null,
|
||||
global: params.global !== undefined ? params.global : false,
|
||||
scope: params.scope !== undefined ? params.scope : 'default',
|
||||
open: params.open !== undefined ? params.open : false,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ import { AIChat } from '../AIChat';
|
||||
import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||
import { InsightsProvider } from '../Insights';
|
||||
import { InsightsProvider, VisitorSessionProvider } from '../Insights';
|
||||
import { SearchContainer } from '../Search';
|
||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||
import { CurrentContentProvider } from '../hooks';
|
||||
@@ -78,16 +78,16 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
revisionId={context.revisionId}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
<InsightsProvider
|
||||
enabled={withTracking}
|
||||
<VisitorSessionProvider
|
||||
appURL={GITBOOK_APP_URL}
|
||||
eventUrl={eventUrl.toString()}
|
||||
visitorCookieTrackingEnabled={customization.insights?.trackingCookie}
|
||||
>
|
||||
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
||||
{children}
|
||||
</AIChatProvider>
|
||||
</InsightsProvider>
|
||||
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
|
||||
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
||||
{children}
|
||||
</AIChatProvider>
|
||||
</InsightsProvider>
|
||||
</VisitorSessionProvider>
|
||||
</CurrentContentProvider>
|
||||
</AdaptiveVisitorContextProvider>
|
||||
</SpaceLayoutContextProvider>
|
||||
@@ -180,9 +180,29 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
<div className="flex gap-2">
|
||||
<SearchContainer
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
isMultiVariants={siteSpaces.length > 1}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.filter(
|
||||
(s) =>
|
||||
s.space.language ===
|
||||
siteSpace.space.language
|
||||
).length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={withSections}
|
||||
section={sections?.current}
|
||||
spaceTitle={siteSpace.title}
|
||||
siteSpaceId={siteSpace.id}
|
||||
siteSpaceIds={siteSpaces
|
||||
.filter(
|
||||
(s) =>
|
||||
s.space.language ===
|
||||
siteSpace.space.language
|
||||
)
|
||||
.map((s) => s.id)}
|
||||
className="max-lg:hidden"
|
||||
viewport="desktop"
|
||||
/>
|
||||
|
||||
@@ -40,6 +40,7 @@ export const variantClasses = {
|
||||
'bg-transparent',
|
||||
'text-tint',
|
||||
'border-0',
|
||||
'contrast-more:border',
|
||||
'shadow-none!',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:text-tint-strong',
|
||||
@@ -80,9 +81,9 @@ export const variantClasses = {
|
||||
],
|
||||
};
|
||||
|
||||
const activeClasses = {
|
||||
export const activeClasses = {
|
||||
primary: 'bg-primary-solid-hover',
|
||||
blank: 'bg-primary-active disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active',
|
||||
blank: 'bg-primary-active contrast-more:bg-primary-12 contrast-more:text-contrast-primary-12 disabled:bg-primary-active text-primary-strong font-medium hover:text-primary-strong disabled:text-primary-strong hover:bg-primary-active',
|
||||
secondary: 'bg-tint-active disabled:bg-tint-active',
|
||||
header: 'bg-header-link/3',
|
||||
};
|
||||
@@ -134,7 +135,7 @@ export const Button = React.forwardRef<
|
||||
typeof icon === 'string' ? (
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
className={tcls('button-leading-icon size-[1em]')}
|
||||
className={tcls('button-leading-icon size-[1em] shrink-0')}
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
@@ -154,6 +155,7 @@ export const Button = React.forwardRef<
|
||||
aria-label={label?.toString()}
|
||||
aria-pressed={active}
|
||||
target={target}
|
||||
data-active={active}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
@@ -166,6 +168,7 @@ export const Button = React.forwardRef<
|
||||
aria-label={label?.toString()}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
data-active={active}
|
||||
{...rest}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
/**
|
||||
* Display a date as a relative time.
|
||||
@@ -28,14 +29,11 @@ export function DateRelative(props: { value: string }) {
|
||||
const date = new Date(value);
|
||||
const diff = now - date.getTime();
|
||||
return (
|
||||
<time
|
||||
data-visual-test="transparent"
|
||||
suppressHydrationWarning={true}
|
||||
dateTime={value}
|
||||
title={date.toLocaleString()}
|
||||
>
|
||||
{formatDiff(language.locale, diff)}
|
||||
</time>
|
||||
<Tooltip label={date.toLocaleString()}>
|
||||
<time data-visual-test="transparent" suppressHydrationWarning={true} dateTime={value}>
|
||||
{formatDiff(language.locale, diff)}
|
||||
</time>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
@@ -146,10 +146,20 @@ export function DropdownMenuItem(
|
||||
active?: boolean;
|
||||
className?: ClassValue;
|
||||
children: React.ReactNode;
|
||||
leadingIcon?: IconName | React.ReactNode;
|
||||
} & LinkInsightsProps &
|
||||
RadixDropdownMenu.DropdownMenuItemProps
|
||||
) {
|
||||
const { children, active = false, href, className, insights, target, ...rest } = props;
|
||||
const {
|
||||
children,
|
||||
active = false,
|
||||
href,
|
||||
className,
|
||||
insights,
|
||||
target,
|
||||
leadingIcon,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const itemClassName = tcls(
|
||||
'rounded-xs straight-corners:rounded-xs circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center',
|
||||
@@ -161,10 +171,22 @@ export function DropdownMenuItem(
|
||||
className
|
||||
);
|
||||
|
||||
const icon = leadingIcon ? (
|
||||
typeof leadingIcon === 'string' ? (
|
||||
<Icon
|
||||
icon={leadingIcon as IconName}
|
||||
className={tcls('size-4 shrink-0', active ? 'text-primary' : 'text-tint-subtle')}
|
||||
/>
|
||||
) : (
|
||||
leadingIcon
|
||||
)
|
||||
) : null;
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<RadixDropdownMenu.Item {...rest} asChild>
|
||||
<Link href={href} insights={insights} className={itemClassName} target={target}>
|
||||
{icon}
|
||||
{children}
|
||||
</Link>
|
||||
</RadixDropdownMenu.Item>
|
||||
@@ -173,6 +195,7 @@ export function DropdownMenuItem(
|
||||
|
||||
return (
|
||||
<RadixDropdownMenu.Item {...rest} className={tcls('px-3 py-1', itemClassName, className)}>
|
||||
{icon}
|
||||
{children}
|
||||
</RadixDropdownMenu.Item>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import { Button, type ButtonProps } from './Button';
|
||||
|
||||
export function SegmentedControl(props: { children: React.ReactNode; className?: ClassValue }) {
|
||||
const { children, className } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
className={tcls(
|
||||
'mb-2 flex flex-wrap gap-1 circular-corners:rounded-3xl rounded-corners:rounded-lg bg-tint p-1',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedControlItem(props: ButtonProps) {
|
||||
const { size = 'medium', className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="blank"
|
||||
size={size}
|
||||
className={tcls(
|
||||
'shrink grow justify-center whitespace-normal not-contrast-more:data-[active=true]:bg-tint-base',
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,10 @@ export const de = {
|
||||
search_no_results_for: 'Keine Ergebnisse für "${1}".',
|
||||
search_no_results: 'Keine Ergebnisse',
|
||||
search_results_count: '${1} Ergebnisse',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Alle Inhalte',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Alle Inhalte',
|
||||
search_scope_default: 'Beste Match',
|
||||
search_scope_all: 'Alles',
|
||||
ask: 'Fragen',
|
||||
search_ask: 'Fragen "${1}"',
|
||||
search_ask_description: 'Finden Sie die Antwort mit ${1}',
|
||||
|
||||
@@ -12,8 +12,10 @@ export const en = {
|
||||
search_no_results_for: 'No results for "${1}".',
|
||||
search_no_results: 'No results',
|
||||
search_results_count: '${1} results',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'All content',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'All content',
|
||||
search_scope_default: 'Best match',
|
||||
search_scope_all: 'Everything',
|
||||
ask: 'Ask',
|
||||
search_ask: 'Ask "${1}"',
|
||||
search_ask_description: 'Find the answer with ${1}',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const es: TranslationLanguage = {
|
||||
search_no_results_for: 'No hay resultados para "${1}".',
|
||||
search_no_results: 'No hay resultados',
|
||||
search_results_count: '${1} resultados',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Todo el contenido',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Todo el contenido',
|
||||
search_scope_default: 'Mejor match',
|
||||
search_scope_all: 'Todo',
|
||||
ask: 'Preguntar',
|
||||
search_ask: 'Preguntar "${1}"',
|
||||
search_ask_description: 'Encuentra la respuesta con ${1}',
|
||||
|
||||
@@ -12,8 +12,10 @@ export const fr = {
|
||||
search_no_results_for: 'Aucun résultat pour « ${1} ».',
|
||||
search_no_results: 'Aucun résultat',
|
||||
search_results_count: '${1} résultats',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Tous les contenus',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Tous les contenus',
|
||||
search_scope_default: 'Meilleur match',
|
||||
search_scope_all: 'Tout',
|
||||
ask: 'Poser une question',
|
||||
search_ask: 'Demander « ${1} »',
|
||||
search_ask_description: 'Trouvez la réponse avec ${1}',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const ja: TranslationLanguage = {
|
||||
search_no_results_for: '"${1}" の結果はありません。',
|
||||
search_no_results: '結果がありません',
|
||||
search_results_count: '${1}件の結果',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: '全てのコンテンツ',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: '全てのコンテンツ',
|
||||
search_scope_default: '最適なマッチ',
|
||||
search_scope_all: 'すべて',
|
||||
ask: '質問する',
|
||||
search_ask: '"${1}" を質問する',
|
||||
search_ask_description: '${1}で答えを見つける',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const nl: TranslationLanguage = {
|
||||
search_no_results_for: 'Geen resultaten voor "${1}".',
|
||||
search_no_results: 'Geen resultaten',
|
||||
search_results_count: '${1} resultaten',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Alle inhoud',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Alle inhoud',
|
||||
search_scope_default: 'Beste match',
|
||||
search_scope_all: 'Alles',
|
||||
ask: 'Vragen',
|
||||
search_ask: 'Vraag "${1}"',
|
||||
search_ask_description: 'Vind het antwoord met ${1}',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const no: TranslationLanguage = {
|
||||
search_no_results_for: 'Ingen resultater for "${1}".',
|
||||
search_no_results: 'Ingen resultater',
|
||||
search_results_count: '${1} resultater',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Alt innhold',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Alt innhold',
|
||||
search_scope_default: 'Beste treff',
|
||||
search_scope_all: 'Alt',
|
||||
ask: 'Spør',
|
||||
search_ask: 'Spør "${1}"',
|
||||
search_ask_description: 'Finn svaret med ${1}',
|
||||
|
||||
@@ -12,8 +12,10 @@ export const pt_br = {
|
||||
search_no_results_for: 'Sem resultados para "${1}".',
|
||||
search_no_results: 'Sem resultados',
|
||||
search_results_count: '${1} resultados',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Todo o conteúdo',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Todo o conteúdo',
|
||||
search_scope_default: 'Melhor match',
|
||||
search_scope_all: 'Tudo',
|
||||
ask: 'Perguntar',
|
||||
search_ask: 'Perguntar "${1}"',
|
||||
search_ask_description: 'Encontre a resposta com ${1}',
|
||||
|
||||
@@ -12,8 +12,10 @@ export const ru = {
|
||||
search_no_results_for: 'Нет результатов для "${1}".',
|
||||
search_no_results: 'Нет результатов',
|
||||
search_results_count: '${1} — число результатов',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: 'Все материалы',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: 'Все материалы',
|
||||
search_scope_default: 'Лучший матч',
|
||||
search_scope_all: 'Всё',
|
||||
ask: 'Спросить',
|
||||
search_ask: 'Спросить "${1}"',
|
||||
search_ask_description: 'Найти ответ с помощью ${1}',
|
||||
|
||||
@@ -14,8 +14,10 @@ export const zh: TranslationLanguage = {
|
||||
search_no_results_for: '没有找到"${1}"的结果。',
|
||||
search_no_results: '没有找到结果',
|
||||
search_results_count: '${1} 个结果',
|
||||
search_scope_space: '${1}',
|
||||
search_scope_all: '所有内容',
|
||||
search_scope_current: '${1}',
|
||||
search_scope_extended: '所有内容',
|
||||
search_scope_default: '最佳匹配',
|
||||
search_scope_all: '全部',
|
||||
ask: '询问',
|
||||
search_ask: '询问"${1}"',
|
||||
search_ask_description: '利用${1}找到答案',
|
||||
|
||||
@@ -10,23 +10,23 @@ export async function serveRobotsTxt(context: GitBookSiteContext) {
|
||||
const isRoot = checkIsRootSiteContext(context);
|
||||
const isIndexable = await isSiteIndexable(context);
|
||||
|
||||
const sitemapPath = linker.toPathInSpace(isRoot ? '/sitemap.xml' : '/sitemap-pages.xml');
|
||||
const sitemapUrl = linker.toAbsoluteURL(sitemapPath);
|
||||
|
||||
const lines = isIndexable
|
||||
? [
|
||||
'User-agent: *',
|
||||
// Disallow other dynamic routes / search queries
|
||||
'Disallow: /*?',
|
||||
// Allow image resizing and icon generation routes for favicons and search results
|
||||
// Disallow only internal search
|
||||
'Disallow: /*?*q=*',
|
||||
'Disallow: /*?*ask=*',
|
||||
// Allow dynamic assets (may include ?)
|
||||
'Allow: /~gitbook/image?*',
|
||||
'Allow: /~gitbook/icon?*',
|
||||
'Allow: /favicon.ico',
|
||||
'Allow: /',
|
||||
`Sitemap: ${linker.toAbsoluteURL(linker.toPathInSpace(isRoot ? '/sitemap.xml' : '/sitemap-pages.xml'))}`,
|
||||
`Sitemap: ${sitemapUrl}`,
|
||||
]
|
||||
: ['User-agent: *', 'Disallow: /'];
|
||||
|
||||
const robotsTxt = lines.join('\n');
|
||||
return new Response(robotsTxt, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
return new Response(lines.join('\n'), { headers: { 'Content-Type': 'text/plain' } });
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ const config: Config = {
|
||||
],
|
||||
var: ['var(--font-family)'],
|
||||
},
|
||||
fontSize: {
|
||||
xxs: ['0.625rem', { lineHeight: '0.75rem' }],
|
||||
},
|
||||
colors: {
|
||||
// Dynamic colors matching the customization settings
|
||||
|
||||
|
||||
Reference in New Issue
Block a user