diff --git a/.changeset/new-rocks-explode.md b/.changeset/new-rocks-explode.md new file mode 100644 index 000000000..4038d69b1 --- /dev/null +++ b/.changeset/new-rocks-explode.md @@ -0,0 +1,5 @@ +--- +"gitbook": minor +--- + +Support site announcement banner diff --git a/packages/gitbook/src/components/Announcement/Announcement.tsx b/packages/gitbook/src/components/Announcement/Announcement.tsx new file mode 100644 index 000000000..0497ce3ab --- /dev/null +++ b/packages/gitbook/src/components/Announcement/Announcement.tsx @@ -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 ( + + ); +} diff --git a/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx b/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx new file mode 100644 index 000000000..cbfd49131 --- /dev/null +++ b/packages/gitbook/src/components/Announcement/AnnouncementBanner.tsx @@ -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 ( +
+
+ + +
+ {announcement.message} + {hasLink ? ( +
+ {contentRef?.icon ? ( + {contentRef?.icon} + ) : null} + {announcement.link?.title && ( + {announcement.link?.title} + )} + +
+ ) : null} +
+
+ {closeable ? ( + + ) : null} +
+
+ ); +} + +/** + * 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', + }, +}; diff --git a/packages/gitbook/src/components/Announcement/AnnouncementDismissedScript.tsx b/packages/gitbook/src/components/Announcement/AnnouncementDismissedScript.tsx new file mode 100644 index 000000000..2956e55ba --- /dev/null +++ b/packages/gitbook/src/components/Announcement/AnnouncementDismissedScript.tsx @@ -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 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 ( +