Skip to content

Commit 82559a0

Browse files
committed
Merge branch 'main' into fix-flash-dark-mode
2 parents bc9ffa0 + d70d566 commit 82559a0

File tree

11 files changed

+273
-23
lines changed

11 files changed

+273
-23
lines changed

.changeset/new-rocks-explode.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": minor
3+
---
4+
5+
Support site announcement banner
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { resolveContentRef } from '@/lib/references';
2+
import type { GitBookSiteContext } from '@v2/lib/context';
3+
import { AnnouncementBanner } from './AnnouncementBanner';
4+
5+
/**
6+
* Server-side component to resolve content refs and pass down to client-side component
7+
*/
8+
export async function Announcement(props: {
9+
context: GitBookSiteContext;
10+
}) {
11+
const { context } = props;
12+
const { customization } = context;
13+
14+
if (
15+
!customization.announcement ||
16+
!customization.announcement.enabled ||
17+
!customization.announcement.message
18+
) {
19+
return null;
20+
}
21+
22+
const resolvedContentRef = customization.announcement?.link
23+
? await resolveContentRef(customization.announcement?.link?.to, context)
24+
: null;
25+
26+
return (
27+
<AnnouncementBanner
28+
announcement={customization.announcement}
29+
contentRef={resolvedContentRef}
30+
/>
31+
);
32+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
'use client';
2+
3+
import * as storage from '@/lib/local-storage';
4+
import type { ResolvedContentRef } from '@/lib/references';
5+
import { tcls } from '@/lib/tailwind';
6+
import type { CustomizationAnnouncement } from '@gitbook/api';
7+
import { Icon, type IconName } from '@gitbook/icons';
8+
import Link from 'next/link';
9+
import { CONTAINER_STYLE } from '../layout';
10+
import { linkStyles } from '../primitives';
11+
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
12+
13+
/**
14+
* Client-side component to enable closing the banner
15+
*/
16+
export function AnnouncementBanner(props: {
17+
announcement: CustomizationAnnouncement;
18+
contentRef: ResolvedContentRef | null;
19+
}) {
20+
const { announcement, contentRef } = props;
21+
22+
const hasLink = announcement.link && contentRef?.href;
23+
const closeable = announcement.style !== 'danger';
24+
25+
const Tag = hasLink ? Link : 'div';
26+
const style = BANNER_STYLES[announcement.style];
27+
28+
return (
29+
<div className="announcement-banner scroll-nojump theme-bold:bg-header-background pt-4 pb-2">
30+
<div className={tcls('relative', CONTAINER_STYLE)}>
31+
<Tag
32+
href={contentRef?.href ?? ''}
33+
className={tcls(
34+
'flex w-full items-start justify-center overflow-hidden rounded-md straight-corners:rounded-none px-4 py-3 text-neutral-strong text-sm theme-bold:ring-1 theme-gradient:ring-1 ring-inset transition-colors',
35+
style.container,
36+
closeable && 'pr-12',
37+
hasLink && style.hover
38+
)}
39+
>
40+
<Icon
41+
icon={style.icon as IconName}
42+
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
43+
/>
44+
<div>
45+
{announcement.message}
46+
{hasLink ? (
47+
<div className={tcls(linkStyles, style.link, 'ml-1 inline')}>
48+
{contentRef?.icon ? (
49+
<span className="mr-1 ml-2 *:inline">{contentRef?.icon}</span>
50+
) : null}
51+
{announcement.link?.title && (
52+
<span className="mr-1">{announcement.link?.title}</span>
53+
)}
54+
<Icon
55+
icon={
56+
announcement.link?.to.kind === 'url'
57+
? 'arrow-up-right'
58+
: 'chevron-right'
59+
}
60+
className={tcls('mb-0.5 inline size-3')}
61+
/>
62+
</div>
63+
) : null}
64+
</div>
65+
</Tag>
66+
{closeable ? (
67+
<button
68+
className={`absolute top-0 right-4 mt-2 mr-2 rounded straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
69+
type="button"
70+
onClick={dismissAnnouncement}
71+
>
72+
<Icon icon="close" className="size-4" />
73+
</button>
74+
) : null}
75+
</div>
76+
</div>
77+
);
78+
}
79+
80+
/**
81+
* Dismiss the announcement banner and store the dismissal state in local storage.
82+
* @see AnnouncementScript
83+
*/
84+
function dismissAnnouncement() {
85+
storage.setItem(ANNOUNCEMENT_STORAGE_KEY, {
86+
visible: false,
87+
at: Date.now(),
88+
});
89+
90+
document.documentElement.classList.add(ANNOUNCEMENT_CSS_CLASS);
91+
}
92+
93+
const BANNER_STYLES = {
94+
info: {
95+
container: 'bg-info ring-info-subtle',
96+
hover: 'hover:bg-info-hover active:bg-info-active',
97+
icon: 'circle-info',
98+
iconColor: 'text-info-subtle',
99+
close: 'hover:bg-tint-base hover:ring-info-subtle',
100+
link: '',
101+
},
102+
warning: {
103+
container: 'bg-warning decoration-warning/6 ring-warning-subtle',
104+
hover: 'hover:bg-warning-hover',
105+
icon: 'circle-exclamation',
106+
iconColor: 'text-warning-subtle',
107+
close: 'hover:bg-tint-base hover:ring-warning-subtle',
108+
link: 'links-default:text-warning links-default:hover:text-warning-strong links-default:decoration-warning/6 links-accent:decoration-warning',
109+
},
110+
danger: {
111+
container: 'bg-danger decoration-danger/6 ring-danger-subtle',
112+
hover: 'hover:bg-danger-hover',
113+
icon: 'triangle-exclamation',
114+
iconColor: 'text-danger-subtle',
115+
close: 'hover:bg-tint-base hover:ring-danger-subtle',
116+
link: 'links-default:text-danger links-default:hover:text-danger-strong links-default:decoration-danger/6 links-accent:decoration-danger',
117+
},
118+
success: {
119+
container: 'bg-success decoration-success/6 ring-success-subtle',
120+
hover: 'hover:bg-success-hover',
121+
icon: 'circle-check',
122+
iconColor: 'text-success-subtle',
123+
close: 'hover:bg-tint-base hover:ring-success-subtle',
124+
link: 'links-default:text-success links-default:hover:text-success-strong links-default:decoration-success/6 links-accent:decoration-success',
125+
},
126+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use client';
2+
3+
import {
4+
ANNOUNCEMENT_CSS_CLASS,
5+
ANNOUNCEMENT_DAYS_TILL_RESET,
6+
ANNOUNCEMENT_STORAGE_KEY,
7+
} from './constants';
8+
import { checkStorageForDismissedScript } from './script';
9+
10+
/**
11+
* Inject a script to read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element as early as possible.
12+
* Bypasses react state to prevent flickering.
13+
*/
14+
export function AnnouncementDismissedScript() {
15+
const scriptArgs = JSON.stringify([
16+
ANNOUNCEMENT_STORAGE_KEY,
17+
ANNOUNCEMENT_DAYS_TILL_RESET,
18+
ANNOUNCEMENT_CSS_CLASS,
19+
]).slice(1, -1);
20+
21+
return (
22+
<script
23+
suppressHydrationWarning
24+
dangerouslySetInnerHTML={{
25+
__html: `(${checkStorageForDismissedScript.toString()})(${scriptArgs})`,
26+
}}
27+
/>
28+
);
29+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* The local storage key for the announcement banner.
3+
*/
4+
export const ANNOUNCEMENT_STORAGE_KEY = '@gitbook/announcement';
5+
/**
6+
* The CSS class to hide the announcement banner. Applies to the <html> element.
7+
*/
8+
export const ANNOUNCEMENT_CSS_CLASS = 'announcement-hidden';
9+
/**
10+
* The number of days until the announcement banner resets.
11+
*/
12+
export const ANNOUNCEMENT_DAYS_TILL_RESET = 7;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './Announcement';
2+
export * from './AnnouncementDismissedScript';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element.
3+
*
4+
* NOTE: this script is stringified and run in the browser, so it must be self-contained and have syntax supported in all browsers.
5+
*/
6+
export function checkStorageForDismissedScript(
7+
storageKey: string,
8+
daysTillReset: number,
9+
cssClass: string
10+
) {
11+
let showBanner = true;
12+
13+
try {
14+
const announcementStateStr = window.localStorage.getItem(storageKey);
15+
const announcementState = announcementStateStr
16+
? JSON.parse(announcementStateStr)
17+
: undefined;
18+
19+
if (announcementState && !announcementState.visible) {
20+
const dismissedAt = announcementState.at;
21+
const nowTime = new Date().getTime();
22+
23+
// Check if enough days have passed since dismissal
24+
const daysSinceDismissal = Math.floor((nowTime - dismissedAt) / (1000 * 60 * 60 * 24));
25+
if (daysSinceDismissal < daysTillReset) {
26+
showBanner = false;
27+
}
28+
}
29+
} catch {}
30+
31+
if (!showBanner) {
32+
document.documentElement.classList.add(cssClass);
33+
}
34+
}

packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { ClientContexts } from './ClientContexts';
3434
import '@gitbook/icons/style.css';
3535
import './globals.css';
3636
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@v2/lib/env';
37+
import { AnnouncementDismissedScript } from '../Announcement';
3738

3839
/**
3940
* Layout shared between the content and the PDF renderer.
@@ -102,6 +103,11 @@ export async function CustomizationRootLayout(props: {
102103
{/* Inject custom font @font-face rules */}
103104
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
104105

106+
{/* Inject a script to detect if the announcmeent banner has been dismissed */}
107+
{'announcement' in customization && customization.announcement?.enabled ? (
108+
<AnnouncementDismissedScript />
109+
) : null}
110+
105111
<style
106112
nonce={
107113
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline

packages/gitbook/src/components/RootLayout/globals.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,7 @@ html {
160160
html.dark {
161161
color-scheme: dark light;
162162
}
163+
164+
html.announcement-hidden .announcement-banner {
165+
@apply hidden;
166+
}

packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { tcls } from '@/lib/tailwind';
1313

1414
import type { VisitorAuthClaims } from '@/lib/adaptive';
1515
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
16+
import { Announcement } from '../Announcement';
1617
import { SpacesDropdown } from '../Header/SpacesDropdown';
1718
import { InsightsProvider } from '../Insights';
1819
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
@@ -62,6 +63,7 @@ export function SpaceLayout(props: {
6263
spaceId={context.space.id}
6364
visitorAuthClaims={visitorAuthClaims}
6465
>
66+
<Announcement context={context} />
6567
<Header withTopHeader={withTopHeader} context={context} />
6668
<div className="scroll-nojump">
6769
<div

packages/gitbook/src/components/primitives/StyledLink.tsx

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,34 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
22

33
import { Link, type LinkProps } from '../primitives/Link';
44

5+
export const linkStyles = [
6+
'underline',
7+
'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin.
8+
'underline-offset-2',
9+
'links-accent:underline-offset-4',
10+
11+
'links-default:decoration-primary/6',
12+
'links-default:text-primary-subtle',
13+
'links-default:hover:text-primary-strong',
14+
'links-default:contrast-more:text-primary',
15+
'links-default:contrast-more:hover:text-primary-strong',
16+
17+
'links-accent:decoration-primary-subtle',
18+
'links-accent:hover:decoration-[3px]',
19+
'links-accent:hover:[text-decoration-skip-ink:none]',
20+
21+
'transition-all',
22+
'duration-100',
23+
];
24+
525
/**
626
* Styled version of Link component.
727
*/
828
export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValue }) {
929
const { style, ...rest } = props;
1030

1131
return (
12-
<Link
13-
{...rest}
14-
className={tcls(
15-
'underline',
16-
'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin.
17-
'underline-offset-2',
18-
'links-accent:underline-offset-4',
19-
20-
'links-default:decoration-primary/6',
21-
'links-default:text-primary-subtle',
22-
'links-default:hover:text-primary-strong',
23-
'links-default:contrast-more:text-primary',
24-
'links-default:contrast-more:hover:text-primary-strong',
25-
26-
'links-accent:decoration-primary-subtle',
27-
'links-accent:hover:decoration-[3px]',
28-
'links-accent:hover:[text-decoration-skip-ink:none]',
29-
30-
'transition-all',
31-
'duration-100',
32-
style
33-
)}
34-
>
32+
<Link {...rest} className={tcls(linkStyles, style)}>
3533
{props.children}
3634
</Link>
3735
);

0 commit comments

Comments
 (0)