mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-17 16:15:22 +00:00
Merge branch 'main' into taran/site-proxy-domain
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Support site announcement banner
|
||||
@@ -0,0 +1,32 @@
|
||||
import { resolveContentRef } from '@/lib/references';
|
||||
import type { GitBookSiteContext } from '@v2/lib/context';
|
||||
import { AnnouncementBanner } from './AnnouncementBanner';
|
||||
|
||||
/**
|
||||
* Server-side component to resolve content refs and pass down to client-side component
|
||||
*/
|
||||
export async function Announcement(props: {
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { context } = props;
|
||||
const { customization } = context;
|
||||
|
||||
if (
|
||||
!customization.announcement ||
|
||||
!customization.announcement.enabled ||
|
||||
!customization.announcement.message
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedContentRef = customization.announcement?.link
|
||||
? await resolveContentRef(customization.announcement?.link?.to, context)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AnnouncementBanner
|
||||
announcement={customization.announcement}
|
||||
contentRef={resolvedContentRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import * as storage from '@/lib/local-storage';
|
||||
import type { ResolvedContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { CustomizationAnnouncement } from '@gitbook/api';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import Link from 'next/link';
|
||||
import { CONTAINER_STYLE } from '../layout';
|
||||
import { linkStyles } from '../primitives';
|
||||
import { ANNOUNCEMENT_CSS_CLASS, ANNOUNCEMENT_STORAGE_KEY } from './constants';
|
||||
|
||||
/**
|
||||
* Client-side component to enable closing the banner
|
||||
*/
|
||||
export function AnnouncementBanner(props: {
|
||||
announcement: CustomizationAnnouncement;
|
||||
contentRef: ResolvedContentRef | null;
|
||||
}) {
|
||||
const { announcement, contentRef } = props;
|
||||
|
||||
const hasLink = announcement.link && contentRef?.href;
|
||||
const closeable = announcement.style !== 'danger';
|
||||
|
||||
const Tag = hasLink ? Link : 'div';
|
||||
const style = BANNER_STYLES[announcement.style];
|
||||
|
||||
return (
|
||||
<div className="announcement-banner scroll-nojump theme-bold:bg-header-background pt-4 pb-2">
|
||||
<div className={tcls('relative', CONTAINER_STYLE)}>
|
||||
<Tag
|
||||
href={contentRef?.href ?? ''}
|
||||
className={tcls(
|
||||
'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',
|
||||
style.container,
|
||||
closeable && 'pr-12',
|
||||
hasLink && style.hover
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={style.icon as IconName}
|
||||
className={`mt-0.5 mr-3 size-4 shrink-0 ${style.iconColor}`}
|
||||
/>
|
||||
<div>
|
||||
{announcement.message}
|
||||
{hasLink ? (
|
||||
<div className={tcls(linkStyles, style.link, 'ml-1 inline')}>
|
||||
{contentRef?.icon ? (
|
||||
<span className="mr-1 ml-2 *:inline">{contentRef?.icon}</span>
|
||||
) : null}
|
||||
{announcement.link?.title && (
|
||||
<span className="mr-1">{announcement.link?.title}</span>
|
||||
)}
|
||||
<Icon
|
||||
icon={
|
||||
announcement.link?.to.kind === 'url'
|
||||
? 'arrow-up-right'
|
||||
: 'chevron-right'
|
||||
}
|
||||
className={tcls('mb-0.5 inline size-3')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Tag>
|
||||
{closeable ? (
|
||||
<button
|
||||
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}`}
|
||||
type="button"
|
||||
onClick={dismissAnnouncement}
|
||||
>
|
||||
<Icon icon="close" className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the announcement banner and store the dismissal state in local storage.
|
||||
* @see AnnouncementScript
|
||||
*/
|
||||
function dismissAnnouncement() {
|
||||
storage.setItem(ANNOUNCEMENT_STORAGE_KEY, {
|
||||
visible: false,
|
||||
at: Date.now(),
|
||||
});
|
||||
|
||||
document.documentElement.classList.add(ANNOUNCEMENT_CSS_CLASS);
|
||||
}
|
||||
|
||||
const BANNER_STYLES = {
|
||||
info: {
|
||||
container: 'bg-info ring-info-subtle',
|
||||
hover: 'hover:bg-info-hover active:bg-info-active',
|
||||
icon: 'circle-info',
|
||||
iconColor: 'text-info-subtle',
|
||||
close: 'hover:bg-tint-base hover:ring-info-subtle',
|
||||
link: '',
|
||||
},
|
||||
warning: {
|
||||
container: 'bg-warning decoration-warning/6 ring-warning-subtle',
|
||||
hover: 'hover:bg-warning-hover',
|
||||
icon: 'circle-exclamation',
|
||||
iconColor: 'text-warning-subtle',
|
||||
close: 'hover:bg-tint-base hover:ring-warning-subtle',
|
||||
link: 'links-default:text-warning links-default:hover:text-warning-strong links-default:decoration-warning/6 links-accent:decoration-warning',
|
||||
},
|
||||
danger: {
|
||||
container: 'bg-danger decoration-danger/6 ring-danger-subtle',
|
||||
hover: 'hover:bg-danger-hover',
|
||||
icon: 'triangle-exclamation',
|
||||
iconColor: 'text-danger-subtle',
|
||||
close: 'hover:bg-tint-base hover:ring-danger-subtle',
|
||||
link: 'links-default:text-danger links-default:hover:text-danger-strong links-default:decoration-danger/6 links-accent:decoration-danger',
|
||||
},
|
||||
success: {
|
||||
container: 'bg-success decoration-success/6 ring-success-subtle',
|
||||
hover: 'hover:bg-success-hover',
|
||||
icon: 'circle-check',
|
||||
iconColor: 'text-success-subtle',
|
||||
close: 'hover:bg-tint-base hover:ring-success-subtle',
|
||||
link: 'links-default:text-success links-default:hover:text-success-strong links-default:decoration-success/6 links-accent:decoration-success',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ANNOUNCEMENT_CSS_CLASS,
|
||||
ANNOUNCEMENT_DAYS_TILL_RESET,
|
||||
ANNOUNCEMENT_STORAGE_KEY,
|
||||
} from './constants';
|
||||
import { checkStorageForDismissedScript } from './script';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Bypasses react state to prevent flickering.
|
||||
*/
|
||||
export function AnnouncementDismissedScript() {
|
||||
const scriptArgs = JSON.stringify([
|
||||
ANNOUNCEMENT_STORAGE_KEY,
|
||||
ANNOUNCEMENT_DAYS_TILL_RESET,
|
||||
ANNOUNCEMENT_CSS_CLASS,
|
||||
]).slice(1, -1);
|
||||
|
||||
return (
|
||||
<script
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(${checkStorageForDismissedScript.toString()})(${scriptArgs})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The local storage key for the announcement banner.
|
||||
*/
|
||||
export const ANNOUNCEMENT_STORAGE_KEY = '@gitbook/announcement';
|
||||
/**
|
||||
* The CSS class to hide the announcement banner. Applies to the <html> element.
|
||||
*/
|
||||
export const ANNOUNCEMENT_CSS_CLASS = 'announcement-hidden';
|
||||
/**
|
||||
* The number of days until the announcement banner resets.
|
||||
*/
|
||||
export const ANNOUNCEMENT_DAYS_TILL_RESET = 7;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './Announcement';
|
||||
export * from './AnnouncementDismissedScript';
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Read the local storage state for the announcement banner and apply the appropriate CSS class to the <html> element.
|
||||
*
|
||||
* NOTE: this script is stringified and run in the browser, so it must be self-contained and have syntax supported in all browsers.
|
||||
*/
|
||||
export function checkStorageForDismissedScript(
|
||||
storageKey: string,
|
||||
daysTillReset: number,
|
||||
cssClass: string
|
||||
) {
|
||||
let showBanner = true;
|
||||
|
||||
try {
|
||||
const announcementStateStr = window.localStorage.getItem(storageKey);
|
||||
const announcementState = announcementStateStr
|
||||
? JSON.parse(announcementStateStr)
|
||||
: undefined;
|
||||
|
||||
if (announcementState && !announcementState.visible) {
|
||||
const dismissedAt = announcementState.at;
|
||||
const nowTime = new Date().getTime();
|
||||
|
||||
// Check if enough days have passed since dismissal
|
||||
const daysSinceDismissal = Math.floor((nowTime - dismissedAt) / (1000 * 60 * 60 * 24));
|
||||
if (daysSinceDismissal < daysTillReset) {
|
||||
showBanner = false;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (!showBanner) {
|
||||
document.documentElement.classList.add(cssClass);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { ClientContexts } from './ClientContexts';
|
||||
import '@gitbook/icons/style.css';
|
||||
import './globals.css';
|
||||
import { GITBOOK_FONTS_URL, GITBOOK_ICONS_TOKEN, GITBOOK_ICONS_URL } from '@v2/lib/env';
|
||||
import { AnnouncementDismissedScript } from '../Announcement';
|
||||
|
||||
/**
|
||||
* Layout shared between the content and the PDF renderer.
|
||||
@@ -96,6 +97,11 @@ export async function CustomizationRootLayout(props: {
|
||||
{/* Inject custom font @font-face rules */}
|
||||
{fontData.type === 'custom' ? <style>{fontData.fontFaceRules}</style> : null}
|
||||
|
||||
{/* Inject a script to detect if the announcmeent banner has been dismissed */}
|
||||
{'announcement' in customization && customization.announcement?.enabled ? (
|
||||
<AnnouncementDismissedScript />
|
||||
) : null}
|
||||
|
||||
<style
|
||||
nonce={
|
||||
//Since I can't get the nonce to work for inline styles, we need to allow unsafe-inline
|
||||
|
||||
@@ -160,3 +160,7 @@ html {
|
||||
html.dark {
|
||||
color-scheme: dark light;
|
||||
}
|
||||
|
||||
html.announcement-hidden .announcement-banner {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
||||
import { InsightsProvider } from '../Insights';
|
||||
import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||
@@ -62,6 +63,7 @@ export function SpaceLayout(props: {
|
||||
spaceId={context.space.id}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
<Announcement context={context} />
|
||||
<Header withTopHeader={withTopHeader} context={context} />
|
||||
<div className="scroll-nojump">
|
||||
<div
|
||||
|
||||
@@ -2,6 +2,26 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { Link, type LinkProps } from '../primitives/Link';
|
||||
|
||||
export const linkStyles = [
|
||||
'underline',
|
||||
'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin.
|
||||
'underline-offset-2',
|
||||
'links-accent:underline-offset-4',
|
||||
|
||||
'links-default:decoration-primary/6',
|
||||
'links-default:text-primary-subtle',
|
||||
'links-default:hover:text-primary-strong',
|
||||
'links-default:contrast-more:text-primary',
|
||||
'links-default:contrast-more:hover:text-primary-strong',
|
||||
|
||||
'links-accent:decoration-primary-subtle',
|
||||
'links-accent:hover:decoration-[3px]',
|
||||
'links-accent:hover:[text-decoration-skip-ink:none]',
|
||||
|
||||
'transition-all',
|
||||
'duration-100',
|
||||
];
|
||||
|
||||
/**
|
||||
* Styled version of Link component.
|
||||
*/
|
||||
@@ -9,29 +29,7 @@ export function StyledLink(props: Omit<LinkProps, 'style'> & { style?: ClassValu
|
||||
const { style, ...rest } = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
{...rest}
|
||||
className={tcls(
|
||||
'underline',
|
||||
'decoration-[max(0.07em,1px)]', // Set the underline to be proportional to the font size, with a minimum. The default is too thin.
|
||||
'underline-offset-2',
|
||||
'links-accent:underline-offset-4',
|
||||
|
||||
'links-default:decoration-primary/6',
|
||||
'links-default:text-primary-subtle',
|
||||
'links-default:hover:text-primary-strong',
|
||||
'links-default:contrast-more:text-primary',
|
||||
'links-default:contrast-more:hover:text-primary-strong',
|
||||
|
||||
'links-accent:decoration-primary-subtle',
|
||||
'links-accent:hover:decoration-[3px]',
|
||||
'links-accent:hover:[text-decoration-skip-ink:none]',
|
||||
|
||||
'transition-all',
|
||||
'duration-100',
|
||||
style
|
||||
)}
|
||||
>
|
||||
<Link {...rest} className={tcls(linkStyles, style)}>
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user