chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
export * from './lib/announcement-banner';
export * from './lib/headers/documentation-header';
export * from './lib/breadcrumbs';
export * from './lib/button';
export * from './lib/call-to-action';
export * from './lib/champion-card';
export * from './lib/champion-perks';
export * from './lib/default-layout';
export * from './lib/headers/header';
export * from './lib/flip-card';
export * from './lib/footer';
export * from './lib/sidebar-container';
export * from './lib/sidebar';
export * from './lib/plugin-card';
export * from './lib/testimonial-card';
export * from './lib/tweet';
export * from './lib/typography';
export * from './lib/github-star-widget';
export * from './lib/youtube.component';
export * from './lib/course-video';
export * from './lib/x-icon';
export * from './lib/discord-icon';
export * from './lib/trusted-by';
export * from './lib/testimonials';
export * from './lib/square-dotted-pattern';
export * from './lib/live-stream-notifier';
export * from './lib/webinar-notifier';
export * from './lib/hubspot-form';
export * from './lib/video-modal';
export * from './lib/video-player';
export * from './lib/global-search-handler';
export { resourceMenuItems } from './lib/headers/menu-items';
export { eventItems } from './lib/headers/menu-items';
export { learnItems } from './lib/headers/menu-items';
export { companyItems } from './lib/headers/menu-items';
export type { MenuItem } from './lib/headers/menu-items';
export { ossProducts as plans } from './lib/headers/menu-items';
export { featuresItems } from './lib/headers/menu-items';
export { DefaultMenuItem } from './lib/headers/default-menu-item';
export { MobileMenuItem } from './lib/headers/mobile-menu-item';
export { SectionsMenu } from './lib/headers/sections-menu';
export { TwoColumnsMenu } from './lib/headers/two-columns-menu';
@@ -0,0 +1,37 @@
import Link from 'next/link';
export function AnnouncementBanner(): JSX.Element {
return (
<div className="group relative border border-y border-zinc-200 bg-zinc-50/40 transition hover:bg-zinc-50 dark:border-zinc-800/40 dark:bg-zinc-800/60 dark:hover:bg-zinc-800">
<div className="mx-auto max-w-7xl px-3 py-3 sm:px-6 lg:px-8">
<div className="text-center">
<p className="text-sm font-medium">
<span className="md:hidden">
<Link
href="https://monorepo.world?utm_source=nx.dev"
className="underline"
>
Monorepo World: October 7, 2024
</Link>
</span>
<span className="hidden md:inline">
<span className="font-semibold">
Monorepo World: October 7, 2024
</span>
</span>
<span className="ml-2 inline-block">
<Link
href="https://monorepo.world?utm_source=nx.dev"
className="font-semibold text-blue-500 underline dark:text-blue-500"
>
<span className="absolute inset-0" aria-hidden="true" />
Join us!
<span aria-hidden="true">&rarr;</span>
</Link>
</span>
</p>
</div>
</div>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { ChevronRightIcon } from '@heroicons/react/24/solid';
import { ProcessedDocument } from '@nx/nx-dev-models-document';
import classNames from 'classnames';
interface Crumb {
id: string;
name: string;
href: string;
current: boolean;
}
const sectionNames: Record<string, string> = {
ci: 'CI',
'extending-nx': 'Extending Nx',
'nx-api': 'Nx API',
api: 'API',
};
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
export function Breadcrumbs({
document,
path,
}: {
document?: ProcessedDocument;
path?: string;
}): JSX.Element {
let crumbs: Crumb[] = [];
if (path) {
const cleanedPath = path.includes('?')
? path.slice(0, path.indexOf('?'))
: path;
crumbs = [
...cleanedPath
.split('/')
.filter(Boolean)
.map((segment, index, segments) => {
const strippedName = segment.includes('#')
? segment.slice(0, segment.indexOf('#'))
: segment;
const name =
sectionNames[strippedName] ||
strippedName.split('-').map(capitalize).join(' ');
return {
id: segment,
name,
// We do not have dedicated page view for executors & generators
href: '/' + segments.slice(0, index + 1).join('/'),
current:
'/' + segments.slice(0, index + 1).join('/') === cleanedPath,
};
}),
];
}
if (document && document.parentDocuments) {
crumbs = document.parentDocuments.map((parentDocument, index) => ({
id: parentDocument.id,
name:
parentDocument.name ||
sectionNames[parentDocument.id] ||
parentDocument.id.split('-').map(capitalize).join(' '),
href: parentDocument.path,
current: index + 1 === document.parentDocuments?.length,
}));
}
if (crumbs.length < 2) {
return <></>;
}
return (
<div>
<nav className="flex" aria-labelledby="breadcrumb">
<ol role="list" className="flex flex-wrap items-center space-x-3">
{crumbs.map((crumb, index) => (
<li
className="m-1 block"
key={crumb.id.concat('-', index.toString())}
>
<div className="flex items-center">
{!!index && (
<ChevronRightIcon
className="h-5 w-5 flex-shrink-0 text-zinc-500"
aria-hidden="true"
/>
)}
<a
href={crumb.href}
className={classNames(
'text-sm font-medium hover:text-zinc-600',
crumb.current ? 'text-zinc-600' : 'text-zinc-400',
!!index ? 'ml-4' : ''
)}
aria-current={crumb.current ? 'page' : undefined}
>
{crumb.name}
</a>
</div>
</li>
))}
</ol>
</nav>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import Link from 'next/link';
import { cx } from '@nx/nx-dev-ui-primitives';
import {
AnchorHTMLAttributes,
ForwardedRef,
forwardRef,
ReactNode,
} from 'react';
type AllowedVariants = 'primary' | 'secondary' | 'contrast';
type AllowedSizes = 'large' | 'default' | 'small';
interface ButtonProps {
variant?: AllowedVariants;
size?: AllowedSizes;
rounded?: 'full' | 'default';
children: ReactNode | ReactNode[];
}
export type ButtonLinkProps = ButtonProps & {
className?: string;
href: string;
title: string;
} & AnchorHTMLAttributes<HTMLAnchorElement>;
const variantStyles: Record<AllowedVariants, string> = {
primary:
'bg-blue-500 dark:bg-blue-500 text-white group-hover:bg-blue-600 dark:group-hover:bg-blue-600 group-focus:ring-2 group-focus:ring-blue-500 dark:group-focus:ring-blue-500 focus:group-ring-offset-2',
secondary:
'border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 group-hover:bg-zinc-50 dark:group-hover:bg-zinc-700 group-focus:ring-2 group-focus:ring-blue-500 dark:group-focus:ring-blue-500 focus:ring-offset-2',
contrast:
'bg-zinc-950 dark:bg-white text-zinc-100 dark:text-zinc-950 group-hover:bg-zinc-800 dark:group-hover:bg-zinc-100 group-focus:ring-2 group-focus:ring-blue-500 dark:group-focus:ring-blue-500 focus:ring-offset-2',
};
const sizes: Record<AllowedSizes, string> = {
large: 'space-x-4 px-4 py-2 text-lg',
default: 'space-x-3 px-4 py-2 text-base',
small: 'space-x-2 px-2.5 py-1.5 text-sm',
};
/**
* Shared layout containing specific button styles.
*/
function getLayoutClassName(className = ''): string {
return cx(
'group relative inline-flex opacity-100 focus:outline-none disabled:opacity-80 disabled:cursor-not-allowed transition no-underline',
className
);
}
/**
* This is the interior of the button that is properly styled.
*/
function ButtonInner({
children,
variant = 'contrast',
size = 'default',
rounded = 'default',
}: ButtonProps): JSX.Element {
return (
<>
<span
className={cx(
'flex h-full w-full items-center justify-center whitespace-nowrap',
rounded === 'full' ? 'rounded-full' : 'rounded-md',
'border border-transparent font-medium shadow-sm transition',
variantStyles[variant],
sizes[size]
)}
>
{children}
</span>
</>
);
}
/**
* Simple button that looks like a button.
*/
export function Button({
children,
className = '',
variant = 'contrast',
size = 'large',
rounded = 'default',
...props
}: ButtonProps & JSX.IntrinsicElements['button']): JSX.Element {
return (
<button {...props} className={getLayoutClassName(className)}>
<ButtonInner variant={variant} size={size} rounded={rounded}>
{children}
</ButtonInner>
</button>
);
}
/**
* A link that looks like a button using Link from NextJS.
*/
export const ButtonLink = forwardRef(function (
{
children,
className = '',
href,
size = 'default',
variant = 'contrast',
title = '',
...props
}: ButtonLinkProps,
ref: ForwardedRef<HTMLAnchorElement>
): JSX.Element {
return (
<Link
ref={ref}
href={href}
title={title}
className={getLayoutClassName(className)}
prefetch={false}
{...props}
>
<ButtonInner variant={variant} size={size}>
{children}
</ButtonInner>
</Link>
);
});
+102
View File
@@ -0,0 +1,102 @@
'use client';
import Link from 'next/link';
import { sendCustomEventViaGtm } from '@nx/nx-dev-feature-analytics';
export interface CTAProps {
mainActionLinkText?: string;
mainActionTitle?: string;
mainActionLink?: string;
}
export function CallToAction({
mainActionTitle = 'Get started with Nx',
mainActionLinkText = 'Get started',
mainActionLink = 'https://cloud.nx.app/get-started?utm_source=nx-dev&utm_medium=homepage_links&utm_campaign=try-nx-cloud',
}: CTAProps): JSX.Element {
return (
<section className="relative isolate px-6 py-32 sm:py-40 lg:px-8">
<svg
className="absolute inset-0 -z-10 h-full w-full [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-black/10 dark:stroke-white/10"
aria-hidden="true"
>
<defs>
<pattern
id="1d4240dd-898f-445f-932d-e2872fd12de3"
width={200}
height={200}
x="50%"
y={0}
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" />
</pattern>
</defs>
<svg
x="50%"
y={0}
className="overflow-visible fill-zinc-200/20 dark:fill-zinc-800/20"
>
<path
d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
strokeWidth={0}
/>
</svg>
<rect
width="100%"
height="100%"
strokeWidth={0}
fill="url(#1d4240dd-898f-445f-932d-e2872fd12de3)"
/>
</svg>
<div
className="pointer-events-none absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl"
aria-hidden="true"
>
<div
className="aspect-[1108/632] w-[69.25rem] flex-none bg-gradient-to-r from-[#80caff] to-[#4f46e5] opacity-20"
style={{
clipPath:
'polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)',
}}
/>
</div>
<div className="mx-auto max-w-2xl text-center">
<h2
id="cta"
className="text-3xl font-medium tracking-tight text-zinc-950 sm:text-5xl dark:text-white"
>
Ready to
<br />
build smarter and ship faster?
</h2>
<div className="mt-10 flex items-center justify-center gap-x-6">
<Link
href={mainActionLink}
title={mainActionTitle}
prefetch={false}
className="rounded-md bg-zinc-950 px-3.5 py-2.5 text-sm font-semibold text-zinc-100 shadow-xs hover:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
>
{mainActionLinkText}
</Link>
<Link
href="/contact"
title="Get in touch"
prefetch={false}
className="group text-sm leading-6 font-semibold text-zinc-950 dark:text-white"
onClick={() =>
sendCustomEventViaGtm('contact-click', 'footer-cta', '')
}
>
Contact us{' '}
<span
aria-hidden="true"
className="inline-block transition group-hover:translate-x-1"
>
</span>
</Link>
</div>
</div>
</section>
);
}
@@ -0,0 +1,45 @@
import { ChatBubbleLeftIcon, MapIcon } from '@heroicons/react/24/solid';
interface ContactDetails {
label: string;
link: string;
}
export interface Champion {
name: string;
location: string;
expertise: string;
imageUrl: string;
contact: ContactDetails[];
}
export function ChampionCard({ data }: { data: Champion }): JSX.Element {
return (
<figure className="relative flex flex-col justify-between rounded-lg border border-zinc-200 bg-white/40 p-4 text-sm shadow-xs transition focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 hover:bg-white dark:border-zinc-800/40 dark:bg-zinc-800/60 dark:hover:bg-zinc-800">
<figcaption className="flex items-center space-x-4">
<img
src={data.imageUrl}
alt={data.name}
className="h-12 w-12 flex-none rounded-full border border-zinc-200 bg-zinc-800/40 object-cover dark:border-zinc-800/40 dark:bg-zinc-200"
loading="lazy"
/>
<div className="flex-auto">
<div className="font-semibold text-zinc-500 dark:text-zinc-300">
<a target="_blank" rel="noreferrer" href={data.contact[0].link}>
<span className="absolute inset-0"></span>
{data.name}
</a>
</div>
<div className="mt-0.5 text-xs text-zinc-400 dark:text-zinc-500">
<MapIcon className="inline h-4 w-4" /> {data.location}
</div>
</div>
</figcaption>
<p className="mt-4">{data.expertise}</p>
<div className="ml mt-4 inline text-xs text-zinc-400 dark:text-zinc-500">
<ChatBubbleLeftIcon className="inline h-4 w-4" />{' '}
{data.contact[0].label}
</div>
</figure>
);
}
+175
View File
@@ -0,0 +1,175 @@
import {
AcademicCapIcon,
BeakerIcon,
ChartBarIcon,
ChatBubbleBottomCenterIcon,
ChatBubbleBottomCenterTextIcon,
CheckBadgeIcon,
CloudArrowDownIcon,
CubeTransparentIcon,
GiftIcon,
HeartIcon,
KeyIcon,
LightBulbIcon,
MicrophoneIcon,
NewspaperIcon,
PencilIcon,
ServerStackIcon,
UserGroupIcon,
UserIcon,
UserPlusIcon,
UsersIcon,
VideoCameraIcon,
} from '@heroicons/react/24/outline';
import { CogIcon } from '@heroicons/react/24/solid';
import { SectionHeading } from './typography';
export function ChampionPerks(): JSX.Element {
return (
<article
id="making-of-champion"
className="relative bg-zinc-50 py-28 dark:bg-zinc-800/40"
>
<div className="mx-auto max-w-7xl px-4 sm:grid sm:grid-cols-2 sm:gap-8 sm:px-6 lg:px-8">
<div>
<header>
<SectionHeading as="h1" variant="subtitle" id="nx-is-fast">
Interested in joining the Nx Champions program yourself?
</SectionHeading>
<SectionHeading
as="p"
variant="title"
id="nx-is-fast"
className="mt-4"
>
The Making of a Champion
</SectionHeading>
</header>
<div className="mt-8 flex gap-16 font-normal">
<p className="max-w-xl text-lg text-zinc-700 dark:text-zinc-400">
If you love Nx and want other people to love Nx too, you may have
the makings of an Nx Champion.
</p>
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-4 pt-12 sm:px-6 lg:px-8 lg:pt-16">
<dl className="grid grid-cols-1 gap-16 sm:grid-cols-2 lg:grid-cols-4">
<div key="Recognition as a Leader" className="group">
<dt>
<div className="relative flex h-12 w-12">
<CheckBadgeIcon
className="h-8 w-8 text-blue-500 dark:text-blue-500"
aria-hidden="true"
/>
<HeartIcon
className="absolute inset-0 h-8 w-8 text-purple-500 opacity-0 transition-all group-hover:-translate-y-1 group-hover:translate-x-8 group-hover:opacity-100 dark:text-fuchsia-500"
aria-hidden="true"
/>
<GiftIcon
className="5 absolute inset-0 h-8 w-8 text-purple-500 opacity-0 transition-all group-hover:translate-x-5 group-hover:translate-y-6 group-hover:opacity-100 dark:text-fuchsia-500"
aria-hidden="true"
/>
</div>
<p className="relative mt-4 text-base font-medium leading-6 text-zinc-900 dark:text-zinc-100">
<span className="absolute -left-4 h-full w-0.5 bg-blue-500 dark:bg-blue-500"></span>
Recognition as a Leader
</p>
</dt>
<dd className="mt-2 text-base text-zinc-500 dark:text-zinc-400">
You'll be listed in the Nx Champion directory above, receive
branded swag for yourself and Nx stickers to give away.
</dd>
</div>
<div key="Content Promotion" className="group">
<dt>
<div className="relative flex h-12 w-12">
<NewspaperIcon
className="h-8 w-8 text-blue-500 dark:text-blue-500"
aria-hidden="true"
/>
<VideoCameraIcon
className="absolute inset-0 h-8 w-8 text-purple-500 opacity-0 transition-all group-hover:-translate-y-1 group-hover:translate-x-8 group-hover:opacity-100 dark:text-fuchsia-500"
aria-hidden="true"
/>
<MicrophoneIcon
className="5 absolute inset-0 h-8 w-8 text-purple-500 opacity-0 transition-all group-hover:translate-x-5 group-hover:translate-y-6 group-hover:opacity-100 dark:text-fuchsia-500"
aria-hidden="true"
/>
</div>
<p className="relative mt-4 text-base font-medium leading-6 text-zinc-900 dark:text-zinc-100">
<span className="absolute -left-4 h-full w-0.5 bg-blue-500 dark:bg-blue-500"></span>
Content Promotion
</p>
</dt>
<dd className="mt-2 text-base text-zinc-500 dark:text-zinc-400">
You can collaborate with other Nx Champions to improve your blog
posts and videos. Nx will promote your content on our social media
channels.
</dd>
</div>
<div key="Special Access" className="group">
<dt>
<div className="relative flex h-12 w-12">
<KeyIcon
className="h-8 w-8 text-blue-500 dark:text-blue-500"
aria-hidden="true"
/>
<LightBulbIcon
className="absolute inset-0 h-8 w-8 text-purple-500 opacity-0 transition-all group-hover:-translate-y-1 group-hover:translate-x-8 group-hover:opacity-100 dark:text-fuchsia-500"
aria-hidden="true"
/>
<ChatBubbleBottomCenterTextIcon
className="5 absolute inset-0 h-8 w-8 text-purple-500 opacity-0 transition-all group-hover:translate-x-5 group-hover:translate-y-6 group-hover:opacity-100 dark:text-fuchsia-500"
aria-hidden="true"
/>
</div>
<p className="relative mt-4 text-base font-medium leading-6 text-zinc-900 dark:text-zinc-100">
<span className="absolute -left-4 h-full w-0.5 bg-blue-500 dark:bg-blue-500"></span>
Special Access
</p>
</dt>
<dd className="mt-2 text-base text-zinc-500 dark:text-zinc-400">
You'll have a dedicated Discord channel with the Nx team and
monthly video calls with Nx team members to share feedback and
brainstorm content ideas.
</dd>
</div>
<div key="Join the Program" className="group">
<dt>
<div className="relative flex h-12 w-12">
<UserGroupIcon
className="h-8 w-8 text-blue-500 dark:text-blue-500"
aria-hidden="true"
/>
<UsersIcon
className="absolute inset-0 h-8 w-8 text-purple-500 opacity-0 transition-all group-hover:-translate-y-1 group-hover:translate-x-8 group-hover:opacity-100 dark:text-fuchsia-500"
aria-hidden="true"
/>
<UserPlusIcon
className="5 absolute inset-0 h-8 w-8 text-purple-500 opacity-0 transition-all group-hover:translate-x-5 group-hover:translate-y-6 group-hover:opacity-100 dark:text-fuchsia-500"
aria-hidden="true"
/>
</div>
<p className="relative mt-4 text-base font-medium leading-6 text-zinc-900 dark:text-zinc-100">
<span className="absolute -left-4 h-full w-0.5 bg-blue-500 dark:bg-blue-500"></span>
Join the Program
</p>
</dt>
<dd className="mt-2 text-base text-zinc-500 dark:text-zinc-400">
When you're ready to join, fill out the{' '}
<a
className="text-zinc-900 underline dark:text-zinc-100"
href="https://forms.gle/wYd9mC3ka64ki96G7"
>
application form
</a>{' '}
and we'll schedule an informal conversation with an Nx team member
to make sure the program is a good fit for you.
</dd>
</div>
</dl>
</div>
</article>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { ButtonLink } from './button';
import { YouTube } from './youtube.component';
export interface CourseVideoProps {
/**
* The YouTube video URL
*/
src: string;
courseTitle: string;
courseUrl: string;
}
export function CourseVideo({
src: videoUrl,
courseTitle,
courseUrl,
}: CourseVideoProps): JSX.Element {
return (
<div className="not-prose mx-auto max-w-4xl pt-4">
<div className="overflow-hidden rounded-xl bg-zinc-50 shadow-xs ring-1 ring-zinc-900/5 dark:bg-zinc-800/50 dark:ring-zinc-700/50">
<div className="aspect-video w-full">
<YouTube
src={videoUrl}
title={courseTitle}
width="100%"
disableRoundedCorners={true}
/>
</div>
<div className="px-6 py-4">
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-center text-xl font-medium text-zinc-900 sm:text-left dark:text-white">
{courseTitle}
</h3>
<ButtonLink
href={courseUrl}
variant="contrast"
size="small"
title={`Watch the full "${courseTitle}" course`}
className="whitespace-nowrap"
>
Watch full course
</ButtonLink>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
'use client';
import { Footer } from './footer';
import { Header } from './headers/header';
import { PropsWithChildren } from 'react';
import { ButtonLinkProps } from './button';
import { useWindowScrollDepth } from '@nx/nx-dev-feature-analytics';
export function DefaultLayout({
isHome = false,
children,
hideBackground = false,
hideHeader = false,
hideFooter = false,
headerCTAConfig,
scrollCTAConfig,
}: {
isHome?: boolean;
hideBackground?: boolean;
hideHeader?: boolean;
hideFooter?: boolean;
headerCTAConfig?: ButtonLinkProps[];
scrollCTAConfig?: ButtonLinkProps[];
} & PropsWithChildren): JSX.Element {
useWindowScrollDepth();
return (
<div className="w-full dark:bg-zinc-950">
{!hideHeader && (
<Header
ctaButtons={headerCTAConfig}
scrollCtaButtons={scrollCTAConfig}
/>
)}
<div className="relative isolate">
{!hideBackground && (
<div
className="absolute inset-x-0 -top-40 -z-10 h-full transform-gpu overflow-hidden blur-3xl sm:-top-80"
aria-hidden="true"
>
<div
className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[46.125rem] -translate-x-1/2 rotate-[35deg] bg-gradient-to-tr from-[#9333ea] to-[#3b82f6] opacity-10 sm:left-[calc(70%-30rem)] sm:w-[92.1875rem] dark:from-[#3b82f6] dark:to-[#9333ea] dark:opacity-15"
style={{
clipPath:
'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 95.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 67.5% 76.7%, 0.1% 64.9%, 77.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 84.1% 44.1%)',
}}
/>
</div>
)}
<main
data-document="main"
className={isHome || hideHeader ? '' : 'py-24 sm:py-32'}
>
{children}
</main>
</div>
<Footer className={hideFooter ? 'hidden' : ''} />
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { FC, SVGProps } from 'react';
export const DiscordIcon: FC<
SVGProps<SVGSVGElement> & { showTitle?: boolean }
> = (props) => {
const { showTitle, ...rest } = props;
return (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
{!!showTitle && <title>Discord</title>}
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
);
};
+87
View File
@@ -0,0 +1,87 @@
'use client';
import cx from 'classnames';
import { ReactNode, createContext, useState } from 'react';
const FlipCardContext = createContext<{
fullDate: string;
onClick?: () => void;
}>({
fullDate: '',
onClick: () => {},
});
export function FlipCard({
isFlippable,
isFlipped,
day,
fullDate,
onFlip,
onClick,
children,
}: {
isFlippable?: boolean;
isFlipped?: boolean;
onFlip?: (day: number, isFlipped: boolean) => void;
day: number;
fullDate?: string;
onClick?: () => void;
children: ReactNode;
}) {
return (
<FlipCardContext.Provider value={{ fullDate: fullDate || '', onClick }}>
<span
onClick={(event) => {
if (isFlippable && !isFlipped) {
onFlip && onFlip(day, true);
event.preventDefault();
} else {
onClick && onClick();
}
}}
className={cx(
'perspective group block',
isFlippable && !isFlipped ? 'cursor-pointer' : 'cursor-default'
)}
>
<div
className={cx(
'preserve-3d relative h-full w-full content-center rounded-lg border-2 bg-white/60 shadow-sm transition duration-200 focus-within:ring-offset-2 dark:bg-zinc-800/90',
isFlippable && isFlipped
? 'my-rotate-y-180 bg-white dark:bg-zinc-800'
: '',
isFlippable
? isFlipped
? 'border-blue-400 dark:border-zinc-800'
: 'border-blue-400 hover:[transform:rotateY(10deg)] dark:border-zinc-800'
: 'border-1 border-zinc-300 dark:border-zinc-800'
)}
>
<FlipCardFront>{fullDate}</FlipCardFront>
{children}
</div>
</span>
</FlipCardContext.Provider>
);
}
export function FlipCardFront({ children }: { children: ReactNode }) {
return (
<div className="backface-hidden absolute flex h-full w-full flex-col items-center justify-center px-2 text-center text-3xl font-bold">
{children}
</div>
);
}
export function FlipCardBack({ children }: { children: ReactNode }) {
return (
<FlipCardContext.Consumer>
{() => (
<div className="my-rotate-y-180 backface-hidden h-full w-full overflow-hidden rounded-md bg-white text-3xl text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100">
<div className="p-4 text-sm sm:text-sm md:text-sm lg:text-lg">
{children}
</div>
</div>
)}
</FlipCardContext.Consumer>
);
}
+404
View File
@@ -0,0 +1,404 @@
import type { JSX } from 'react';
import { HeartIcon } from '@heroicons/react/24/solid';
import { ThemeSwitcher } from '@nx/nx-dev-ui-theme';
import Link from 'next/link';
import { DiscordIcon } from './discord-icon';
import { VersionPicker } from './version-picker';
// Use NX_DEV_URL if set, otherwise default to empty string for relative URLs
const nxDevUrl = process.env.NX_DEV_URL || '';
const navigation = {
nx: [
{ name: 'Status', href: 'https://status.nx.app' },
{ name: 'Security', href: 'https://security.nx.app' },
],
nxCloud: [
{ name: 'App', href: 'https://cloud.nx.app' },
{
name: 'Docs',
href: '/docs/features/ci-features',
},
{
name: 'Pricing',
href: `${nxDevUrl}/nx-cloud#plans`,
},
{ name: 'Terms', href: 'https://cloud.nx.app/terms' },
],
solutions: [
{ name: 'Nx', href: nxDevUrl || 'https://nx.dev' },
{
name: 'Nx Cloud',
href: `${nxDevUrl}/nx-cloud`,
},
{
name: 'Enterprise',
href: `${nxDevUrl}/enterprise`,
},
],
resources: [
{
name: 'Blog',
href: `${nxDevUrl}/blog`,
},
{
name: 'Youtube',
href: 'https://youtube.com/@nxdevtools',
},
{
name: 'Community',
href: `${nxDevUrl}/community`,
},
{
name: 'Customers',
href: `${nxDevUrl}/customers`,
},
{
name: 'Plugin Registry',
href: `/docs/plugin-registry`,
},
],
company: [
{
name: 'About us',
href: `${nxDevUrl}/company`,
},
{
name: 'Careers',
href: `${nxDevUrl}/careers`,
},
{
name: 'Brands & Guidelines',
href: `${nxDevUrl}/brands`,
},
{
name: 'Contact us',
href: `${nxDevUrl}/contact`,
},
],
social: [
{
name: 'Discord',
label: 'Community channel',
href: 'https://go.nx.dev/community',
icon: (props: any) => <DiscordIcon {...props} />,
},
{
name: 'GitHub',
label: 'Nx is open source, check the code on GitHub',
href: 'https://github.com/nrwl/nx?utm_source=nx.dev',
icon: (props: any) => (
<svg fill="currentColor" viewBox="0 0 16 16" {...props}>
{/*<title>GitHub</title>*/}
<path d="M8 0a8 8 0 0 0-2.53 15.59c.4.07.55-.17.55-.38l-.01-1.49c-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.42 7.42 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48l-.01 2.2c0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8a8 8 0 0 0-8-8z" />
</svg>
),
},
{
name: 'X',
label: 'Latest news on X',
href: 'https://x.com/NxDevTools?utm_source=nx.dev',
icon: (props: any) => (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/*<title>X</title>*/}
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" />
</svg>
),
},
{
name: 'Bluesky',
label: 'Latest news on Bluesky',
href: 'https://bsky.app/profile/nx.dev?utm_source=nx.dev',
icon: (props: any) => (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/*<title>Bluesky</title>*/}
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" />
</svg>
),
},
{
name: 'Youtube',
label: 'Youtube channel',
href: 'https://www.youtube.com/@NxDevtools?utm_source=nx.dev',
icon: (props: any) => (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/*<title>YouTube</title>*/}
<path d="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14c-1.88-.5-9.38-.5-9.38-.5s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.87.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.54 15.57V8.43L15.82 12l-6.28 3.57z" />
</svg>
),
},
{
name: 'LinkedIn',
label: 'Nx on LinkedIn',
href: 'https://www.linkedin.com/company/nxdevtools',
icon: (props: any) => (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/*<title>LinkedIn</title>*/}
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
),
},
{
name: 'Newsletter',
label: 'The Newsletter',
href: 'https://go.nrwl.io/nx-newsletter?utm_source=nx.dev',
icon: (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
/>
</svg>
),
},
],
};
export function Footer({
className = '',
disableThemeSwitcher = false,
useDomainPrefix = false,
}: {
className?: string;
disableThemeSwitcher?: boolean;
useDomainPrefix?: boolean;
} = {}): JSX.Element {
const prefixHref = (href: string) => {
if (useDomainPrefix && href.startsWith('/')) {
return `${nxDevUrl}${href}`;
}
return href;
};
return (
<footer
className={`bg-white dark:bg-zinc-950 ${className}`}
aria-labelledby="footer-heading"
>
<h2 id="footer-heading" className="sr-only">
Footer
</h2>
<div className="mx-auto max-w-7xl px-4 pt-12 transition-opacity sm:px-6 lg:px-8 lg:pt-16 lg:opacity-50 lg:hover:opacity-100">
<div className="xl:grid xl:grid-cols-3 xl:gap-8">
<div className="space-y-4 text-zinc-700 xl:col-span-1 dark:text-zinc-300">
<svg
className="h-14 subpixel-antialiased"
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>Nx</title>
<path d="M11.987 14.138l-3.132 4.923-5.193-8.427-.012 8.822H0V4.544h3.691l5.247 8.833.005-3.998 3.044 4.759zm.601-5.761c.024-.048 0-3.784.008-3.833h-3.65c.002.059-.005 3.776-.003 3.833h3.645zm5.634 4.134a2.061 2.061 0 0 0-1.969 1.336 1.963 1.963 0 0 1 2.343-.739c.396.161.917.422 1.33.283a2.1 2.1 0 0 0-1.704-.88zm3.39 1.061c-.375-.13-.8-.277-1.109-.681-.06-.08-.116-.17-.176-.265a2.143 2.143 0 0 0-.533-.642c-.294-.216-.68-.322-1.18-.322a2.482 2.482 0 0 0-2.294 1.536 2.325 2.325 0 0 1 4.002.388.75.75 0 0 0 .836.334c.493-.105.46.36 1.203.518v-.133c-.003-.446-.246-.55-.75-.733zm2.024 1.266a.723.723 0 0 0 .347-.638c-.01-2.957-2.41-5.487-5.37-5.487a5.364 5.364 0 0 0-4.487 2.418c-.01-.026-1.522-2.39-1.538-2.418H8.943l3.463 5.423-3.379 5.32h3.54l1.54-2.366 1.568 2.366h3.541l-3.21-5.052a.7.7 0 0 1-.084-.32 2.69 2.69 0 0 1 2.69-2.691h.001c1.488 0 1.736.89 2.057 1.308.634.826 1.9.464 1.9 1.541a.707.707 0 0 0 1.066.596zm.35.133c-.173.372-.56.338-.755.639-.176.271.114.412.114.412s.337.156.538-.311c.104-.231.14-.488.103-.74z" />
</svg>
<p className="text-sm">Smart Monorepos · Fast Builds</p>
<div className="flex flex-wrap space-x-6">
{navigation.social.map((item) => (
<Link
key={item.name}
href={item.href}
title={item.label}
prefetch={false}
className="text-sm text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
<span className="sr-only">{item.name}</span>
<item.icon className="h-6 w-6" aria-hidden="true" />
</Link>
))}
</div>
<div className="flex items-center gap-3 text-sm">
{navigation.nx.map((item) =>
item.href.startsWith('http') ? (
<a
key={item.name}
href={item.href}
title={item.name}
target="_blank"
rel="noreferer"
className="text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
{item.name}
</a>
) : (
<Link
key={item.name}
href={prefixHref(item.href)}
title={item.name}
prefetch={false}
className="text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
{item.name}
</Link>
)
)}
</div>
{disableThemeSwitcher ? null : (
<div className="flex items-center text-sm">
Theme <ThemeSwitcher />
</div>
)}
</div>
<div className="mt-12 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
<div className="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Resources
</h3>
<ul role="list" className="mt-4 space-y-4 p-0">
{navigation.resources.map((item) => (
<li key={item.name} className="list-none">
{item.href.startsWith('http') ? (
<a
href={item.href}
target="_blank"
title={item.name}
rel="noreferer"
className="text-sm text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
{item.name}
</a>
) : (
<Link
href={prefixHref(item.href)}
prefetch={false}
title={item.name}
className="text-sm text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
{item.name}
</Link>
)}
</li>
))}
</ul>
</div>
<div className="mt-12 md:mt-0">
<h3 className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Solutions
</h3>
<ul role="list" className="mt-4 space-y-4 p-0">
{navigation.solutions.map((item) => (
<li key={item.name} className="list-none">
<Link
href={prefixHref(item.href)}
prefetch={false}
title={item.name}
className="text-sm text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
</div>
<div className="md:grid md:grid-cols-2 md:gap-8">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Nx Cloud
</h3>
<ul role="list" className="mt-4 space-y-4 p-0">
{navigation.nxCloud.map((item) => (
<li key={item.name} className="list-none">
{item.href.startsWith('http') ? (
<a
href={item.href}
title={item.name}
target="_blank"
rel="noreferer"
className="text-sm text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
{item.name}
</a>
) : (
<Link
href={prefixHref(item.href)}
prefetch={false}
title={item.name}
className="text-sm text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
{item.name}
</Link>
)}
</li>
))}
</ul>
</div>
<div className="mt-12 md:mt-0">
<h3 className="text-sm font-semibold uppercase tracking-wider text-zinc-400">
Company
</h3>
<ul role="list" className="mt-4 space-y-4 p-0">
{navigation.company.map((item) => (
<li key={item.name} className="list-none">
<Link
href={prefixHref(item.href)}
prefetch={false}
title={item.name}
className="text-sm text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
{item.name}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
<div className="mt-20 border-t border-zinc-200 p-2 dark:border-zinc-800">
<div className="flex items-center gap-1 text-sm text-zinc-400 xl:justify-center">
<span>&copy; {new Date().getFullYear()} made with</span>
<HeartIcon className="inline h-4 w-4" />
<span>by</span>
<Link
href={prefixHref('/company')}
prefetch={false}
title="Company"
className="inline-flex items-center text-zinc-500 no-underline hover:text-zinc-600 dark:hover:text-zinc-400"
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className="h-5 w-5"
>
<path d="m12 14.1-3.1 5-5.2-8.5v8.9H0v-15h3.7l5.2 8.9v-4l3 4.7zm.6-5.7V4.5H8.9v3.9h3.7zm5.6 4.1a2 2 0 0 0-2 1.3 2 2 0 0 1 2.4-.7c.4.2 1 .4 1.3.3a2.1 2.1 0 0 0-1.7-.9zm3.4 1c-.4 0-.8-.2-1.1-.6l-.2-.3a2.1 2.1 0 0 0-.5-.6 2 2 0 0 0-1.2-.3 2.5 2.5 0 0 0-2.3 1.5 2.3 2.3 0 0 1 4 .4.8.8 0 0 0 .9.3c.5 0 .4.4 1.2.5v-.1c0-.4-.3-.5-.8-.7zm2 1.3a.7.7 0 0 0 .4-.6c0-3-2.4-5.5-5.4-5.5a5.4 5.4 0 0 0-4.5 2.4l-1.5-2.4H8.9l3.5 5.4L9 19.5h3.6L14 17l1.6 2.4h3.5l-3.1-5a.7.7 0 0 1 0-.3 2.7 2.7 0 0 1 2.6-2.7c1.5 0 1.7.9 2 1.3.7.8 2 .5 2 1.5a.7.7 0 0 0 1 .6zm.4.2c-.2.3-.6.3-.8.6-.1.3.1.4.1.4s.4.2.6-.3V15z" />
</svg>
</Link>
</div>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,58 @@
import { sendCustomEventViaGtm } from '@nx/nx-dev-feature-analytics';
import type { ReactElement } from 'react';
export const GithubIcon = (props: any) => {
return (
<svg
fill="currentColor"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
{...props}
>
{/*<title>GitHub</title>*/}
<path d="M8 0a8 8 0 0 0-2.53 15.59c.4.07.55-.17.55-.38l-.01-1.49c-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.42 7.42 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48l-.01 2.2c0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8a8 8 0 0 0-8-8z" />
</svg>
);
};
export function GitHubStarWidget({
starsCount,
}: {
starsCount: number;
}): ReactElement {
const formatStars = (count: number) => {
if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k';
} else {
return count;
}
};
const handleClick = (eventAction: string) => {
sendCustomEventViaGtm(
eventAction,
'githubstars-toc-sidebar',
'githubstarswidget'
);
};
return (
<div className="relative mx-2 flex items-center justify-center gap-2 rounded-md border border-slate-300 bg-white p-2 text-xs text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700 print:hidden">
<div className="flex items-center gap-2">
<GithubIcon aria-hidden="true" className="h-4 w-4" />
<span className="font-semibold">{formatStars(starsCount)}</span>
</div>
<a
href="https://github.com/nrwl/nx"
target="_blank"
rel="noreferrer noopener"
className="flex items-center gap-2 border-transparent font-bold text-slate-700 no-underline dark:text-slate-200"
onClick={() => handleClick('githubstars_buttonclick')}
>
<span className="absolute inset-0" />
<span className="whitespace-nowrap">Give us a Star!</span>
</a>
</div>
);
}
@@ -0,0 +1,37 @@
'use client';
import { useEffect } from 'react';
const SEARCH_STORAGE_KEY = 'nx-open-search';
const SEARCH_EXPIRY_MS = 30000; // 30 seconds
/**
* GlobalSearchHandler - Listens for Cmd+K / Ctrl+K keyboard shortcuts
* and redirects to the docs page with the search modal open.
*
* This component should be included in the root layout of non-docs pages
* to provide a consistent search experience across the entire site.
*
* Uses sessionStorage to signal Astro docs to open the search modal.
*/
export function GlobalSearchHandler(): null {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Cmd+K (Mac) or Ctrl+K (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
// Store timestamp in sessionStorage for Astro docs to read
sessionStorage.setItem(SEARCH_STORAGE_KEY, Date.now().toString());
// Redirect to docs
window.location.assign('/docs/getting-started/intro');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// This component doesn't render anything
return null;
}
@@ -0,0 +1,57 @@
import { ElementType } from 'react';
import type { MenuItem } from './menu-items';
import cx from 'classnames';
import Link from 'next/link';
export function DefaultMenuItem({
as = 'div',
className = '',
item,
...rest
}: {
as?: ElementType;
className?: string;
item: MenuItem;
}): JSX.Element {
const hasExternalLink =
item.href.startsWith('http') || item.href.startsWith('//');
const Tag = as;
return (
<Tag
className={cx(
'relative flex flex-1 gap-4 rounded-lg px-2 py-4 transition duration-150 ease-in-out',
item.isHighlight
? 'bg-zinc-50 hover:bg-zinc-100 dark:bg-zinc-800/80 dark:hover:bg-zinc-700/60'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800/60',
className
)}
{...rest}
>
{item.icon ? (
<item.icon aria-hidden="true" className="h-5 w-5 shrink-0" />
) : null}
<div className="grow">
<Link
href={item.href}
title={item.name}
target={hasExternalLink ? '_blank' : '_self'}
className="text-sm font-medium text-zinc-900 dark:text-zinc-200"
prefetch={false}
>
{item.name}
{item.isNew ? (
<span className="float-right inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30">
new
</span>
) : null}
<span className="absolute inset-0" />
</Link>
{item.description ? (
<p className="mt-0.5 text-xs text-zinc-400 dark:text-zinc-500">
{item.description}
</p>
) : null}
</div>
</Tag>
);
}
@@ -0,0 +1,421 @@
'use client';
import {
Fragment,
type MouseEvent,
ReactElement,
useState,
useEffect,
} from 'react';
import { NxCloudAnimatedIcon, NxIcon } from '@nx/nx-dev-ui-icons';
import {
Bars3Icon,
ChevronDownIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { AlgoliaSearch } from '@nx/nx-dev-feature-search';
import cx from 'classnames';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { ButtonLink } from '../button';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { resourceMenuItems } from './menu-items';
import { SectionsMenu } from './sections-menu';
import { DiscordIcon } from '../discord-icon';
import { VersionPicker } from '../version-picker';
import { sendCustomEventViaGtm } from '@nx/nx-dev-feature-analytics';
function Menu({ tabs }: { tabs: any[] }): ReactElement {
return (
<div className="hidden sm:block">
<nav
role="documentation-nav"
aria-label="Tabs"
className="-mb-px flex gap-6"
>
{tabs.map((tab) => (
<Link
key={tab.name}
href={tab.href}
className={cx(
tab.current
? 'border-blue-500 text-blue-600 dark:border-blue-500 dark:text-blue-500'
: 'border-transparent hover:text-zinc-900 dark:hover:text-blue-400',
'whitespace-nowrap border-b-2 py-2 text-sm font-medium'
)}
aria-current={tab.current ? 'page' : undefined}
prefetch={false}
>
{tab.name}
</Link>
))}
</nav>
</div>
);
}
export function DocumentationHeader({
isNavOpen,
toggleNav,
}: {
isNavOpen: boolean;
toggleNav: (value: boolean) => void;
}): ReactElement {
const router = useRouter();
const [currentPath, setCurrentPath] = useState<string>('');
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
setCurrentPath(router.asPath);
}, [router.asPath]);
const isCI: boolean = isClient && currentPath.startsWith('/ci');
const isExtendingNx: boolean =
isClient && currentPath.startsWith('/extending-nx');
const isPlugins: boolean =
isClient && currentPath.startsWith('/plugin-registry');
const isChangelog: boolean = isClient && currentPath.startsWith('/changelog');
const isAiChat: boolean = isClient && currentPath.startsWith('/ai-chat');
const isNx: boolean =
!isCI && !isExtendingNx && !isPlugins && !isChangelog && !isAiChat;
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
if (isClient) {
router.push('/brands');
}
};
// Use the new docs URL when Astro docs are enabled
const docsUrl = '/docs/getting-started/intro';
const sections = [
{ name: 'Nx', href: docsUrl, current: isNx },
{
name: 'CI',
href: '/docs/features/ci-features',
current: isCI,
},
{
name: 'Extending Nx',
href: '/docs/extending-nx/intro',
current: isExtendingNx,
},
{
name: 'Plugins',
href: '/docs/plugin-registry',
current: isPlugins,
},
{
name: 'Changelog',
href: '/changelog',
current: isChangelog,
},
{
name: 'AI Chat',
href: '/ai-chat',
current: isAiChat,
},
];
const communityLinks = [
{
name: 'Discord',
label: 'Community channel',
href: 'https://go.nx.dev/community',
icon: (props: any) => <DiscordIcon {...props} />,
},
{
name: 'X',
label: 'Latest news',
href: 'https://x.com/NxDevTools?utm_source=nx.dev',
icon: (props: any) => (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/*<title>X</title>*/}
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" />
</svg>
),
},
{
name: 'Bluesky',
label: 'Latest news on Bluesky',
href: 'https://bsky.app/profile/nx.dev?utm_source=nx.dev',
icon: (props: any) => (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/*<title>Bluesky</title>*/}
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" />
</svg>
),
},
{
name: 'Youtube',
label: 'Youtube channel',
href: 'https://www.youtube.com/@NxDevtools?utm_source=nx.dev',
icon: (props: any) => (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/*<title>YouTube</title>*/}
<path d="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14c-1.88-.5-9.38-.5-9.38-.5s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.87.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.54 15.57V8.43L15.82 12l-6.28 3.57z" />
</svg>
),
},
{
name: 'LinkedIn',
label: 'Nx on LinkedIn',
href: 'https://www.linkedin.com/company/nxdevtools',
icon: (props: any) => (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
{/*<title>LinkedIn</title>*/}
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
</svg>
),
},
{
name: 'GitHub',
label: 'Nx is open source, check the code on GitHub',
href: 'https://github.com/nrwl/nx?utm_source=nx.dev',
icon: (props: any) => (
<svg
fill="currentColor"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
{...props}
>
{/*<title>GitHub</title>*/}
<path d="M8 0a8 8 0 0 0-2.53 15.59c.4.07.55-.17.55-.38l-.01-1.49c-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.42 7.42 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48l-.01 2.2c0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8a8 8 0 0 0-8-8z" />
</svg>
),
},
];
return (
<div className="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/60 print:hidden">
<div className="mx-auto flex w-full items-center gap-4 lg:px-8 lg:py-4">
{/*MOBILE MENU*/}
<div className="flex w-full items-center lg:hidden">
<button
type="button"
className="flex px-4 py-4 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
onClick={() => toggleNav(!isNavOpen)}
>
<span className="sr-only">Open sidebar</span>
{isNavOpen ? (
<XMarkIcon className="mr-3 h-6 w-6" />
) : (
<Bars3Icon className="mr-3 h-6 w-6" aria-hidden="true" />
)}
<span className="font-medium">
{sections.find((x) => x.current)?.name}
</span>
</button>
{/*SEARCH*/}
</div>
{/*LOGO*/}
<div className="flex items-center gap-4">
<Link
href="/"
className="flex flex-grow items-center px-4 text-zinc-900 lg:px-0 dark:text-white"
prefetch={false}
onContextMenu={handleContextMenu}
>
<span className="sr-only">
Nx Left-click: Home. Right-click: Brands.
</span>
<NxIcon aria-hidden="true" className="h-8 w-8" />
</Link>
<Link
href={docsUrl}
className="ml-2 hidden items-center px-4 text-zinc-900 lg:flex lg:px-0 dark:text-white"
prefetch={false}
onClick={() =>
sendCustomEventViaGtm(
'documentation-click',
'header-navigation',
'documentation-header'
)
}
>
<span className="text-xl font-bold uppercase tracking-wide">
Docs
</span>
</Link>
<VersionPicker />
</div>
{/*SEARCH*/}
{/*NAVIGATION*/}
<div className="hidden flex-shrink-0 lg:flex">
<nav
role="menu"
className="hidden items-center justify-center space-x-2 text-sm lg:flex"
>
<h2 className="sr-only">Main navigation</h2>
<Link
href="/blog"
title="Blog"
className="hidden px-3 py-2 font-medium leading-tight hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
>
Blog
</Link>
{/*RESOURCES*/}
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton
className={cx(
open ? 'text-blue-500 dark:text-blue-500' : '',
'group inline-flex items-center px-3 py-2 font-medium leading-tight outline-0 dark:text-zinc-200'
)}
>
<span className="transition duration-150 ease-in-out group-hover:text-blue-500 dark:group-hover:text-blue-500">
Resources
</span>
<ChevronDownIcon
className={cx(
open
? 'rotate-180 transform text-blue-500 dark:text-blue-500'
: '',
'ml-2 h-3 w-3 transition duration-150 ease-in-out group-hover:text-blue-500 dark:group-hover:text-blue-500'
)}
aria-hidden="true"
/>
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute left-60 z-30 mt-3 w-max max-w-2xl -translate-x-1/2 transform lg:left-20">
<SectionsMenu sections={resourceMenuItems} />
</PopoverPanel>
</Transition>
</>
)}
</Popover>
<div className="hidden h-6 w-px bg-zinc-200 md:block dark:bg-zinc-700" />
<Link
href="/ai"
title="AI"
className="hidden gap-2 px-3 py-2 font-medium leading-tight hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
>
AI
</Link>
<Link
href="/nx-cloud"
title="Nx Cloud"
className="hidden gap-2 px-3 py-2 font-medium leading-tight hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
>
Nx Cloud
</Link>
<div className="hidden h-6 w-px bg-zinc-200 md:block dark:bg-zinc-700" />
<Link
href="/enterprise"
title="Enterprise"
className="hidden gap-2 px-3 py-2 font-medium leading-tight hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
>
Enterprise
</Link>
</nav>
</div>
<div className="hidden flex-grow lg:flex">{/* SPACER */}</div>
<div className="hidden flex-shrink-0 lg:flex">
<nav
role="menu"
className="items-justified hidden justify-center space-x-2 lg:flex"
>
<ButtonLink
href="/contact"
title="Contact"
variant="secondary"
size="small"
onClick={() =>
sendCustomEventViaGtm(
'contact-click',
'header-cta',
'documentation-header'
)
}
>
Contact
</ButtonLink>
<ButtonLink
href="https://cloud.nx.app/get-started?utm_source=nx-dev&utm_medium=documentation-header&utm_campaign=try-nx-cloud"
title="Try Nx Cloud for free"
variant="contrast"
size="small"
onClick={() =>
sendCustomEventViaGtm(
'login-click',
'header-cta',
'documentation-header'
)
}
>
Try Nx Cloud for free
</ButtonLink>
</nav>
</div>
</div>
<div className="mx-auto hidden w-full items-center px-4 sm:space-x-10 sm:px-6 lg:flex lg:px-8">
<Menu tabs={sections} />
<div className="flex-grow"></div>
<nav
aria-labelledby="community-links"
className="block min-w-36 space-x-2 text-right"
>
{communityLinks.map((item) => (
<a
key={item.name}
title={item.label}
href={item.href}
target="_blank"
rel="noreferrer"
className="inline-flex p-1"
>
<span className="sr-only">{item.label}</span>
<item.icon className="h-4 w-4" aria-hidden="true" />
</a>
))}
</nav>
</div>
</div>
);
}
+713
View File
@@ -0,0 +1,713 @@
'use client';
import {
Dialog,
DialogPanel,
DialogTitle,
Disclosure,
DisclosureButton,
Popover,
PopoverButton,
Transition,
TransitionChild,
} from '@headlessui/react';
import {
Bars4Icon,
ChevronDownIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import cx from 'classnames';
import Link from 'next/link';
import {
Fragment,
type MouseEvent,
ReactElement,
useEffect,
useState,
} from 'react';
import { ButtonLink, ButtonLinkProps } from '../button';
import {
enterpriseItems,
professionalServicesItems,
resourceMenuItems,
solutionsItems,
} from './menu-items';
import { MobileMenuItem } from './mobile-menu-item';
import { SectionsMenu } from './sections-menu';
import { AlgoliaSearch } from '@nx/nx-dev-feature-search';
import { GitHubIcon, NxIcon } from '@nx/nx-dev-ui-icons';
import { useRouter } from 'next/navigation';
import { sendCustomEventViaGtm } from '@nx/nx-dev-feature-analytics';
interface HeaderProps {
ctaButtons?: ButtonLinkProps[];
scrollCtaButtons?: ButtonLinkProps[];
}
export function Header({
ctaButtons,
scrollCtaButtons,
}: HeaderProps): ReactElement {
let [isOpen, setIsOpen] = useState(false);
// Use the new docs URL when Astro docs are enabled
const docsUrl = '/docs/getting-started/intro';
let [isScrolled, setIsScrolled] = useState(false);
const router = useRouter();
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
router.push('/brands');
};
// We need to close the popover if the route changes or the window is resized to prevent the popover from being stuck open.
const checkSizeAndClosePopover = () => {
const breakpoint = 1024; // This is the standard Tailwind lg breakpoint value
if (window.innerWidth < breakpoint) {
setIsOpen(false);
}
};
useEffect(() => {
window.addEventListener('resize', checkSizeAndClosePopover);
return () => {
window.removeEventListener('resize', checkSizeAndClosePopover);
};
}, []);
// Scroll detection for CTA button transition
useEffect(() => {
const shouldTrackScroll = scrollCtaButtons && scrollCtaButtons.length > 0;
if (!shouldTrackScroll) return undefined;
const handleScroll = () => {
const scrollPosition = window.scrollY;
setIsScrolled(scrollPosition > 100);
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [scrollCtaButtons?.length]);
const defaultCtaButtons: ButtonLinkProps[] = [
{
href: '/contact',
variant: 'secondary',
size: 'small',
title: 'Contact Us',
children: <span>Contact</span>,
onClick: () =>
sendCustomEventViaGtm('contact-click', 'header-cta', 'page-header'),
},
{
href: 'https://cloud.nx.app/get-started?utm_source=nx-dev&utm_medium=header',
variant: 'contrast',
size: 'small',
target: '_blank',
title: 'Try Nx Cloud for free',
children: 'Try Nx Cloud for free',
onClick: () =>
sendCustomEventViaGtm('login-click', 'header-cta', 'page-header'),
},
];
// const getButtonsToRender = () => {
// if (ctaButtons && ctaButtons.length > 0) return ctaButtons;
// if (scrollCtaButtons && scrollCtaButtons.length > 0 && isScrolled)
// return scrollCtaButtons;
// return defaultCtaButtons;
// };
// const buttonsToRender = getButtonsToRender();
return (
<div className="fixed inset-x-0 top-0 isolate z-[5] flex px-4 print:hidden">
<div
className="absolute inset-x-0 top-0 mx-auto h-20 max-w-7xl backdrop-blur-sm"
style={{
maskImage:
'linear-gradient(to bottom, #000000 20%, transparent calc(100% - 20%))',
}}
/>
{/*DESKTOP*/}
<div className="mx-auto mt-2 hidden w-full max-w-7xl items-center justify-between space-x-10 rounded-xl border border-zinc-200/40 bg-white/70 px-4 py-2 shadow-xs backdrop-blur-xl backdrop-saturate-150 lg:flex dark:border-zinc-800/60 dark:bg-zinc-950/40">
{/*PRIMARY NAVIGATION*/}
<div className="flex flex-shrink-0 text-sm">
{/*LOGO*/}
<Link
href="/"
className="mr-4 flex items-center text-zinc-900 dark:text-white"
prefetch={false}
onContextMenu={handleContextMenu}
>
<span className="sr-only">
Nx Left-click: Home. Right-click: Brands.
</span>
<NxIcon aria-hidden="true" className="h-8 w-8" />
</Link>
<nav
role="menu"
className="items-justified flex items-center justify-center space-x-2 py-0.5"
>
<h2 className="sr-only">Main navigation</h2>
<Link
href={docsUrl}
title="Documentation"
className="hidden px-3 py-2 leading-tight font-medium hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
onClick={() =>
sendCustomEventViaGtm(
'documentation-click',
'header-navigation',
'page-header'
)
}
>
Docs
</Link>
<Link
href="/blog"
title="Blog"
className="hidden px-3 py-2 leading-tight font-medium hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
>
Blog
</Link>
{/*SOLUTIONS*/}
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton
className={cx(
open ? 'text-blue-500 dark:text-blue-500' : '',
'group inline-flex items-center px-3 py-2 leading-tight font-medium outline-0 dark:text-zinc-200'
)}
>
<span className="transition duration-150 ease-in-out group-hover:text-blue-500 dark:group-hover:text-blue-500">
Solutions
</span>
<ChevronDownIcon
className={cx(
open
? 'rotate-180 transform text-blue-500 dark:text-blue-500'
: '',
'ml-2 h-3 w-3 transition duration-150 ease-in-out group-hover:text-blue-500 dark:group-hover:text-blue-500'
)}
aria-hidden="true"
/>
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-60 z-30 mt-3 w-max max-w-xl -translate-x-1/2 transform lg:left-20">
<SectionsMenu
sections={{
'By roles': solutionsItems,
'For enterprises': enterpriseItems,
'Professional services': professionalServicesItems,
}}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
{/*RESOURCES*/}
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton
className={cx(
open ? 'text-blue-500 dark:text-blue-500' : '',
'group inline-flex items-center px-3 py-2 leading-tight font-medium outline-0 dark:text-zinc-200'
)}
>
<span className="transition duration-150 ease-in-out group-hover:text-blue-500 dark:group-hover:text-blue-500">
Resources
</span>
<ChevronDownIcon
className={cx(
open
? 'rotate-180 transform text-blue-500 dark:text-blue-500'
: '',
'ml-2 h-3 w-3 transition duration-150 ease-in-out group-hover:text-blue-500 dark:group-hover:text-blue-500'
)}
aria-hidden="true"
/>
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute left-60 z-30 mt-3 w-max max-w-2xl -translate-x-1/2 transform lg:left-20">
<SectionsMenu sections={resourceMenuItems} />
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<div className="hidden h-6 w-px bg-zinc-200 md:block dark:bg-zinc-700" />
<Link
href="/nx-cloud"
title="Nx Cloud"
className="hidden gap-2 px-3 py-2 leading-tight font-medium hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
onClick={() =>
sendCustomEventViaGtm(
'nx-cloud-click',
'header-cta',
'page-header'
)
}
>
Nx Cloud
</Link>
<Link
href="/nx-cloud#plans"
title="Nx Cloud Pricing"
className="hidden gap-2 px-3 py-2 leading-tight font-medium hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
onClick={() =>
sendCustomEventViaGtm(
'pricing-click',
'header-cta',
'page-header'
)
}
>
Pricing
</Link>
<div className="hidden h-6 w-px bg-zinc-200 md:block dark:bg-zinc-700" />
{/*ENTERPRISE*/}
<Link
href="/enterprise"
title="Nx for Enterprises"
className="hidden gap-2 px-3 py-2 leading-tight font-semibold hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
onClick={() =>
sendCustomEventViaGtm(
'enterprise-click',
'header-cta',
'page-header'
)
}
>
Enterprise
</Link>
</nav>
</div>
{/*SECONDARY NAVIGATION*/}
<div className="flex-shrink-0 text-sm">
<nav className="flex items-center justify-center space-x-1">
<div className="hidden md:block">
<div className="relative flex justify-end">
{/* Default buttons */}
<div
className={`flex space-x-2 transition-all duration-700 ease-in-out ${
scrollCtaButtons &&
scrollCtaButtons.length > 0 &&
isScrolled
? 'opacity-0 blur-sm'
: 'blur-0 opacity-100'
}`}
>
{(ctaButtons && ctaButtons.length > 0
? ctaButtons
: defaultCtaButtons
).map((buttonProps, index) => (
<ButtonLink key={`default-${index}`} {...buttonProps} />
))}
</div>
{/* Scroll CTA buttons */}
{scrollCtaButtons && scrollCtaButtons.length > 0 && (
<div
className={`absolute inset-0 flex justify-end space-x-2 transition-all duration-700 ease-in-out ${
isScrolled ? 'blur-0 opacity-100' : 'opacity-0 blur-sm'
}`}
>
{scrollCtaButtons.map((buttonProps, index) => (
<ButtonLink key={`scroll-${index}`} {...buttonProps} />
))}
</div>
)}
</div>
</div>
<a
title="Nx is open source, check the code on GitHub"
href="https://github.com/nrwl/nx"
target="_blank"
rel="noreferrer"
className="inline-flex items-center px-3 py-2 opacity-60 hover:opacity-90"
>
<span className="sr-only">Nx on GitHub</span>
<GitHubIcon aria-hidden="true" className="h-5 w-5" />
</a>
</nav>
</div>
</div>
{/*MOBILE*/}
<div className="relative mx-auto flex w-full items-center justify-between p-4 lg:hidden">
<div className="flex w-full items-center justify-between">
{/*LOGO*/}
<Link
href="/"
className="flex items-center text-zinc-900 dark:text-white"
prefetch={false}
>
<span className="sr-only">Nx</span>
<NxIcon aria-hidden="true" className="h-8 w-8" />
</Link>
<div className="flex items-center gap-6">
<a
title="Nx is open source, check the code on GitHub"
href="https://github.com/nrwl/nx"
target="_blank"
rel="noreferrer"
className="inline-flex items-center px-3 py-2 opacity-60 hover:opacity-90"
>
<span className="sr-only">Nx on GitHub</span>
<GitHubIcon aria-hidden="true" className="h-5 w-5" />
</a>
{/*MENU*/}
<button onClick={() => setIsOpen(!isOpen)} className="shrink-0 p-2">
<Bars4Icon
aria-hidden="true"
title="Open navigation menu"
strokeWidth="2"
className="h-6 w-6"
/>
<span className="sr-only">Open navigation panel</span>
</button>
</div>
</div>
</div>
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
onClose={() => setIsOpen(!isOpen)}
>
<div className="fixed inset-0" />
<div className="fixed inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden">
<div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full">
<TransitionChild
as={Fragment}
enter="transform transition ease-in-out duration-250 sm:duration-500"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-250 sm:duration-500"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<DialogPanel className="pointer-events-auto w-screen">
<div className="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl dark:bg-zinc-900">
<div className="px-4 sm:px-6">
<div className="flex items-start justify-between">
<DialogTitle>
<Link
href="/"
className="flex items-center text-zinc-900 dark:text-white"
prefetch={false}
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="currentColor"
>
<title>Nx</title>
<path d="M11.987 14.138l-3.132 4.923-5.193-8.427-.012 8.822H0V4.544h3.691l5.247 8.833.005-3.998 3.044 4.759zm.601-5.761c.024-.048 0-3.784.008-3.833h-3.65c.002.059-.005 3.776-.003 3.833h3.645zm5.634 4.134a2.061 2.061 0 0 0-1.969 1.336 1.963 1.963 0 0 1 2.343-.739c.396.161.917.422 1.33.283a2.1 2.1 0 0 0-1.704-.88zm3.39 1.061c-.375-.13-.8-.277-1.109-.681-.06-.08-.116-.17-.176-.265a2.143 2.143 0 0 0-.533-.642c-.294-.216-.68-.322-1.18-.322a2.482 2.482 0 0 0-2.294 1.536 2.325 2.325 0 0 1 4.002.388.75.75 0 0 0 .836.334c.493-.105.46.36 1.203.518v-.133c-.003-.446-.246-.55-.75-.733zm2.024 1.266a.723.723 0 0 0 .347-.638c-.01-2.957-2.41-5.487-5.37-5.487a5.364 5.364 0 0 0-4.487 2.418c-.01-.026-1.522-2.39-1.538-2.418H8.943l3.463 5.423-3.379 5.32h3.54l1.54-2.366 1.568 2.366h3.541l-3.21-5.052a.7.7 0 0 1-.084-.32 2.69 2.69 0 0 1 2.69-2.691h.001c1.488 0 1.736.89 2.057 1.308.634.826 1.9.464 1.9 1.541a.707.707 0 0 0 1.066.596zm.35.133c-.173.372-.56.338-.755.639-.176.271.114.412.114.412s.337.156.538-.311c.104-.231.14-.488.103-.74z" />
</svg>
<span className="sr-only">Nx</span>
</Link>
</DialogTitle>
<div className="ml-3 flex h-7 items-center">
<button
type="button"
className="dark:hovers:text-blue-500 relative rounded-md text-zinc-600 hover:text-blue-500 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none dark:text-zinc-400 dark:focus:ring-blue-500"
onClick={() => setIsOpen(!isOpen)}
>
<span className="absolute -inset-2.5" />
<span className="sr-only">
Close navigation panel
</span>
<XMarkIcon
aria-hidden="true"
className="h-6 w-6"
/>
</button>
</div>
</div>
</div>
<div className="relative mt-6 flex-1 px-4 sm:px-6">
<div className="relative">
{/* Default mobile button */}
<div
className={`space-y-2 transition-all duration-500 ease-in-out ${
scrollCtaButtons &&
scrollCtaButtons.length > 0 &&
isScrolled
? 'opacity-0 blur-sm'
: 'blur-0 opacity-100'
}`}
>
<ButtonLink
href="https://cloud.nx.app/get-started"
variant="contrast"
size="small"
target="_blank"
title="Try Nx Cloud for free"
className="w-full"
onClick={() =>
sendCustomEventViaGtm(
'get-started-click',
'mobile-header-cta',
'mobile-navigation'
)
}
>
Get started
</ButtonLink>
</div>
{/* Scroll CTA mobile buttons */}
{scrollCtaButtons && scrollCtaButtons.length > 0 && (
<div
className={`absolute inset-0 space-y-2 transition-all duration-500 ease-in-out ${
isScrolled
? 'blur-0 opacity-100'
: 'opacity-0 blur-sm'
}`}
>
{scrollCtaButtons.map((buttonProps, index) => (
<ButtonLink
key={`mobile-scroll-${index}`}
{...buttonProps}
className="w-full"
/>
))}
</div>
)}
</div>
<div className="mt-4 divide-y divide-zinc-200 border-b border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
<Link
href={docsUrl}
title="Documentation"
className="block py-4 leading-tight font-medium hover:text-blue-500 dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
onClick={() =>
sendCustomEventViaGtm(
'documentation-click',
'mobile-header-navigation',
'page-header'
)
}
>
Docs
</Link>
<Link
href="/blog"
title="Blog"
className="block py-4 leading-tight font-medium hover:text-blue-500 dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
>
Blog
</Link>
{/*SOLUTIONS*/}
<Disclosure as="div">
{({ open }) => (
<>
<DisclosureButton
className={cx(
open
? 'text-blue-500 dark:text-blue-500'
: 'tex-zinc-800 dark:text-zinc-200',
'flex w-full items-center justify-between py-4 text-left text-base font-medium focus:outline-none'
)}
>
<span>Solutions</span>
<ChevronDownIcon
aria-hidden="true"
className={cx(
open
? 'rotate-180 transform text-blue-500 dark:text-blue-500'
: 'tex-zinc-800 dark:text-zinc-200',
'h-3 w-3 transition duration-150 ease-in-out group-hover:text-blue-500 dark:group-hover:text-blue-500'
)}
/>
</DisclosureButton>
<Disclosure.Panel
as="ul"
className="space-y-1 pb-2"
>
{[
...Object.values(solutionsItems).flat(),
...Object.values(enterpriseItems).flat(),
...Object.values(
professionalServicesItems
).flat(),
].map((item) => (
<MobileMenuItem
key={item.name}
item={item}
/>
))}
</Disclosure.Panel>
</>
)}
</Disclosure>
{/*RESOURCES*/}
<Disclosure as="div">
{({ open }) => (
<>
<DisclosureButton
className={cx(
open
? 'text-blue-500 dark:text-blue-500'
: 'tex-zinc-800 dark:text-zinc-200',
'flex w-full items-center justify-between py-4 text-left text-base font-medium focus:outline-none'
)}
>
<span>Resources</span>
<ChevronDownIcon
aria-hidden="true"
className={cx(
open
? 'rotate-180 transform text-blue-500 dark:text-blue-500'
: 'tex-zinc-800 dark:text-zinc-200',
'h-3 w-3 transition duration-150 ease-in-out group-hover:text-blue-500 dark:group-hover:text-blue-500'
)}
/>
</DisclosureButton>
<Disclosure.Panel
as="ul"
className="space-y-1 pb-2"
>
{Object.values(resourceMenuItems)
.flat()
.map((item) => (
<MobileMenuItem
key={item.name}
item={item}
/>
))}
</Disclosure.Panel>
</>
)}
</Disclosure>
<Link
href="/nx-cloud"
title="Nx Cloud"
className="flex w-full gap-2 py-4 leading-tight font-medium hover:text-blue-500 dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
onClick={() =>
sendCustomEventViaGtm(
'nx-cloud-click',
'mobile-header-cta',
'page-header'
)
}
>
Nx Cloud
</Link>
<Link
href="/nx-cloud#plans"
title="Nx Cloud Pricing"
className="hidden gap-2 px-3 py-2 leading-tight font-medium hover:text-blue-500 md:inline-flex dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
onClick={() =>
sendCustomEventViaGtm(
'pricing-click',
'mobile-header-cta',
'page-header'
)
}
>
Pricing
</Link>
<Disclosure as="div">
{({ open }) => (
<>
<DisclosureButton
className={cx(
open
? 'text-blue-500 dark:text-blue-500'
: 'tex-zinc-800 dark:text-zinc-200',
'flex w-full items-center justify-between py-4 text-left text-base font-medium focus:outline-none'
)}
>
<span>Enterprise</span>
<ChevronDownIcon
aria-hidden="true"
className={cx(
open
? 'rotate-180 transform text-blue-500 dark:text-blue-500'
: 'tex-zinc-800 dark:text-zinc-200',
'h-3 w-3 transition duration-150 ease-in-out group-hover:text-blue-500 dark:group-hover:text-blue-500'
)}
/>
</DisclosureButton>
<Disclosure.Panel
as="ul"
className="space-y-1 pb-2"
>
{Object.values(enterpriseItems)
.flat()
.map((item) => (
<MobileMenuItem
key={item.name}
item={item}
/>
))}
</Disclosure.Panel>
</>
)}
</Disclosure>
<Link
href="/contact"
title="Contact"
className="block py-4 leading-tight font-medium hover:text-blue-500 dark:text-zinc-200 dark:hover:text-blue-500"
prefetch={false}
onClick={() =>
sendCustomEventViaGtm(
'contact-click',
'mobile-header-cta',
'page-header'
)
}
>
Contact
</Link>
</div>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</div>
</Dialog>
</Transition>
</div>
);
}
@@ -0,0 +1,350 @@
import {
AcademicCapIcon,
BoltIcon,
CircleStackIcon,
CodeBracketIcon,
CubeIcon,
NewspaperIcon,
PlayCircleIcon,
ShareIcon,
Squares2X2Icon,
ChatBubbleBottomCenterTextIcon,
ArrowUpCircleIcon,
UserGroupIcon,
ComputerDesktopIcon,
GlobeAltIcon,
MicrophoneIcon,
VideoCameraIcon,
CheckBadgeIcon,
LifebuoyIcon,
BookOpenIcon,
ShieldCheckIcon,
ServerStackIcon,
ArrowTrendingUpIcon,
CommandLineIcon,
UsersIcon,
DocumentChartBarIcon,
DocumentTextIcon,
} from '@heroicons/react/24/outline';
import { FC, SVGProps } from 'react';
import { DiscordIcon } from '../discord-icon';
import { BuildingOfficeIcon } from '@heroicons/react/24/solid';
import { NxAgentsIcon, NxReplayIcon } from '@nx/nx-dev-ui-icons';
export interface MenuItem {
name: string;
href: string;
description: string | null;
icon: FC<SVGProps<SVGSVGElement>> | null;
isHighlight: boolean;
isNew: boolean;
}
export const featuresItems: Record<string, MenuItem[]> = {
'': [
{
name: 'Run Tasks',
// description: 'Run one or many tasks in parallel.',
description: null,
href: '/docs/features/run-tasks',
icon: BoltIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Cache Task Results',
// description: 'Speeds up your local workflow.',
description: null,
href: '/docs/features/cache-task-results',
icon: CircleStackIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Explore Your Workspace',
// description: 'See interactions for tasks and modules.',
description: null,
href: '/docs/features/explore-graph',
icon: ShareIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Automate Updating Dependencies',
// description: 'Keep running on latest without effort.',
description: null,
href: '/docs/features/automate-updating-dependencies',
icon: ArrowUpCircleIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Enforce Module Boundaries',
// description: 'Partition your code into defined units.',
description: null,
href: '/docs/features/enforce-module-boundaries',
icon: Squares2X2Icon,
isNew: false,
isHighlight: false,
},
{
name: 'Manage Releases',
// description: 'Versioning, changelog, publishing.',
description: null,
href: '/docs/features/manage-releases',
icon: CubeIcon,
isNew: false,
isHighlight: false,
},
],
'Nx Cloud Features': [
{
name: 'Use Remote Caching (Nx Replay)',
description: 'Zero-config, fast & secure remote cache solution.',
href: '/docs/features/ci-features/remote-cache',
icon: NxReplayIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Distribute Task Execution (Nx Agents)',
description:
'One-line config for distributing tasks across multiple machines',
href: '/docs/features/ci-features/distribute-task-execution',
icon: NxAgentsIcon,
isNew: false,
isHighlight: false,
},
],
'Nx Enterprise Features': [
{
name: 'Run Conformance Rules',
description: null,
href: '/docs/enterprise/conformance',
icon: CheckBadgeIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Define Project Owners',
description: null,
href: '/docs/enterprise/owners',
icon: UserGroupIcon,
isNew: false,
isHighlight: false,
},
],
};
export const ossProducts: MenuItem[] = [
{
name: 'Nx',
description: 'Smart Monorepos - Fast Builds',
href: '/docs/getting-started/intro',
icon: null,
isNew: false,
isHighlight: false,
},
{
name: 'Nx Console',
description: 'Editor integration for VSCode, Cursor and IntelliJ IDEs',
href: '/docs/getting-started/editor-setup',
icon: null,
isNew: false,
isHighlight: false,
},
];
export const learnItems: MenuItem[] = [
{
name: 'Step by step tutorials',
description: null,
href: '/docs/getting-started/tutorials',
icon: AcademicCapIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Podcasts',
description: null,
href: '/podcast',
icon: MicrophoneIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Webinars',
description: null,
href: '/webinar',
icon: ComputerDesktopIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Nx Video Courses',
description: null,
href: '/courses',
icon: PlayCircleIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Newsletter',
description: null,
href: 'https://go.nrwl.io/nx-newsletter',
icon: NewspaperIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Community',
description: null,
href: '/community',
icon: ChatBubbleBottomCenterTextIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Discord',
description: null,
href: 'https://go.nx.dev/community',
icon: DiscordIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Books',
description: null,
href: '/resources-library?filterBy=book',
icon: BookOpenIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Case Studies',
description: null,
href: '/resources-library?filterBy=case-study',
icon: DocumentChartBarIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Whitepapers',
description: null,
href: '/resources-library?filterBy=whitepaper',
icon: DocumentTextIcon,
isNew: false,
isHighlight: false,
},
];
export const eventItems: MenuItem[] = [
{
name: 'Nx Live',
description: null,
href: 'https://www.youtube.com/playlist?list=PLakNactNC1dE8KLQ5zd3fQwu_yQHjTmR5',
icon: VideoCameraIcon,
isNew: false,
isHighlight: false,
},
];
export const solutionsItems: MenuItem[] = [
{
name: 'Developers',
description:
'Accelerate your CI with Nx: smart cache sharing, flaky-test autoretries, parallel runs & dynamic agents.',
href: '/solutions/engineering',
icon: CommandLineIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Engineering Managers',
description:
'Boost efficiency with powerful monorepos and intelligent CI. Build, test, and deploy faster, freeing teams to innovate.',
href: '/solutions/management',
icon: BoltIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Platform & DevOps Teams',
description:
'Get dependable, out-of-the-box CI that scales effortlessly. Cut costs and boost speed with smart caching, distribution, and enhanced security.',
icon: ServerStackIcon,
href: '/solutions/platform',
isNew: false,
isHighlight: false,
},
{
name: 'CTOs & VPs of Engineering',
description:
'Supercharge engineering ROI with faster delivery, lower CI costs, and fewer risks. Scale safely and future-proof your stack.',
icon: ArrowTrendingUpIcon,
href: '/solutions/leadership',
isNew: false,
isHighlight: false,
},
];
export const companyItems: MenuItem[] = [
{
name: 'About Us',
description: null,
href: '/company',
icon: UserGroupIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Customers',
description: null,
icon: BuildingOfficeIcon,
href: '/customers',
isNew: false,
isHighlight: false,
},
{
name: 'Partners',
description: null,
icon: LifebuoyIcon,
href: '/partners',
isNew: false,
isHighlight: false,
},
];
export const enterpriseItems: MenuItem[] = [
{
name: 'Overview',
description:
"Accelerate your organization's journey to tighter collaboration, better developer experience, and speed…lots of speed.",
href: '/enterprise',
icon: BuildingOfficeIcon,
isNew: false,
isHighlight: false,
},
{
name: 'Security',
description:
'Protect your codebase from artifact poisoning with infrastructure-first security.',
icon: ShieldCheckIcon,
href: '/enterprise/security',
isNew: false,
isHighlight: false,
},
];
export const professionalServicesItems: MenuItem[] = [
{
name: 'Nx Labs',
description:
'From expert training to hands-on engineering support, we meet teams where they are and help them move forward with confidence.',
href: '/contact/labs',
icon: UsersIcon,
isNew: false,
isHighlight: false,
},
];
export const resourceMenuItems = {
Learn: learnItems,
Events: eventItems,
Company: companyItems,
};
@@ -0,0 +1,55 @@
import type { ElementType, ReactElement } from 'react';
import type { MenuItem } from './menu-items';
import cx from 'classnames';
import Link from 'next/link';
export function MobileMenuItem({
as = 'div',
className = '',
item,
...rest
}: {
as?: ElementType;
className?: string;
item: MenuItem;
}): ReactElement {
const hasExternalLink =
item.href.startsWith('http') || item.href.startsWith('//');
const Tag = as;
return (
<Tag
className={cx(
'items-top relative flex flex-1 gap-2 rounded-lg py-3',
item.isHighlight ? 'bg-zinc-50 px-2 dark:bg-zinc-800/80' : '',
className
)}
{...rest}
>
{item.icon ? (
<item.icon aria-hidden="true" className="mt-1.5 size-4 shrink-0" />
) : null}
<div className="grow">
<Link
href={item.href}
title={item.name}
target={hasExternalLink ? '_blank' : '_self'}
className="text-sm font-medium text-zinc-900 dark:text-zinc-200"
prefetch={false}
>
{item.name}
{item.isNew ? (
<span className="float-right inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30">
new
</span>
) : null}
<span className="absolute inset-0" />
</Link>
{item.description ? (
<p className="mt-0.5 text-xs text-zinc-400 dark:text-zinc-500">
{item.description}
</p>
) : null}
</div>
</Tag>
);
}
@@ -0,0 +1,29 @@
import type { MenuItem } from './menu-items';
import { DefaultMenuItem } from './default-menu-item';
export function SectionsMenu({
sections,
}: {
sections: Record<string, MenuItem[]>;
}): JSX.Element {
return (
<div className="flex flex-col gap-2 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<div className="divide-y divide-zinc-200 dark:divide-zinc-800">
{Object.keys(sections).map((section) => (
<div key={section}>
{section ? (
<h5 className="px-4 pt-6 text-sm text-zinc-500 dark:text-zinc-400">
{section}
</h5>
) : undefined}
<div className="grid grid-cols-2 gap-2 p-2">
{sections[section].map((item) => (
<DefaultMenuItem key={item.name} item={item} />
))}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,23 @@
import type { MenuItem } from './menu-items';
import { DefaultMenuItem } from './default-menu-item';
export function TwoColumnsMenu({ items }: { items: MenuItem[] }): JSX.Element {
return (
<div className="flex flex-col gap-2 overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-zinc-200 dark:bg-zinc-900 dark:ring-zinc-800">
<div className="grid grid-cols-2 gap-2 p-2">
{items
.filter((i) => !i.isHighlight)
.map((item) => (
<DefaultMenuItem key={item.name} item={item} />
))}
<div className="col-span-2 flex gap-2">
{items
.filter((i) => i.isHighlight)
.map((item) => (
<DefaultMenuItem key={item.name} item={item} />
))}
</div>
</div>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
'use client';
import { Component } from 'react';
/**
* From https://www.npmjs.com/package/react-hubspot-form
*/
declare const window: {
hbspt: any;
};
declare const Calendly: any;
interface HubspotFormProps {
region?: string;
portalId?: string;
formId?: string;
calendlyFormId?: string | null;
targetId?: string;
noScript?: boolean;
loading?: any;
onSubmit?: (formData) => void;
onReady?: (form) => void;
}
export class HubspotForm extends Component<
HubspotFormProps,
{ loaded: boolean }
> {
static defaultProps = {
calendlyFormId: null,
};
id: string;
el?: HTMLDivElement | null = null;
calendlyInitAttempts: number = 0;
constructor(props) {
super(props);
this.state = {
loaded: false,
};
this.id = HubspotForm.buildTargetId(props);
this.createForm = this.createForm.bind(this);
this.findFormElement = this.findFormElement.bind(this);
}
private static buildTargetId(props: HubspotFormProps): string {
if (props.targetId) {
return props.targetId;
}
const parts = ['reactHubspotForm'];
if (props.portalId) {
parts.push(props.portalId);
}
if (props.formId) {
parts.push(props.formId);
}
if (props.calendlyFormId) {
parts.push(props.calendlyFormId);
}
return parts.join('-');
}
createForm(): void {
if (typeof window === 'undefined') return;
if (window.hbspt) {
// protect against component unmounting before window.hbspt is available
if (this.el === null) {
return;
}
const props: HubspotFormProps = {
...this.props,
};
delete props.loading;
delete props.noScript;
delete props.onSubmit;
delete props.onReady;
const options = {
...props,
target: `#${this.el?.getAttribute(`id`)}`,
onFormSubmit: ($form) => {
// ref: https://developers.hubspot.com/docs/methods/forms/advanced_form_options
const formData = $form.serializeArray();
if (this.props.onSubmit) {
this.props.onSubmit(formData);
}
},
};
window.hbspt.forms.create(options);
} else {
setTimeout(this.createForm, 1);
}
}
findFormElement() {
// protect against component unmounting before form is added
if (this.el === null) {
return;
}
const form = this.el?.querySelector(`iframe`);
if (form) {
this.setState({ loaded: true });
if (this.props.onReady) {
this.props.onReady(form);
}
this.initCalendlyIfAvailable();
} else {
setTimeout(this.findFormElement, 1);
}
}
initCalendlyIfAvailable() {
try {
if (
this.props.calendlyFormId &&
typeof this.props.calendlyFormId === 'string' &&
this.props.formId &&
typeof Calendly !== 'undefined' &&
typeof Calendly.initHubspotForm === 'function'
) {
Calendly.initHubspotForm({
id: this.props.formId,
url: `https://calendly.com/api/form_builder/forms/${this.props.calendlyFormId}/submissions`,
});
return;
}
} catch {}
if (this.props.calendlyFormId && this.calendlyInitAttempts < 20) {
this.calendlyInitAttempts++;
setTimeout(() => this.initCalendlyIfAvailable(), 150);
}
}
override componentDidMount() {
// HubSpot scripts are loaded via GTM; createForm will retry until available.
this.createForm();
this.findFormElement();
}
override render() {
return (
<>
<div
ref={(el) => {
this.el = el;
}}
id={this.id}
style={{ display: this.state.loaded ? 'block' : 'none' }}
/>
{!this.state.loaded && this.props.loading}
</>
);
}
}
@@ -0,0 +1,114 @@
'use client';
import { useState, useEffect, ReactElement } from 'react';
import { motion } from 'framer-motion';
import { MonorepoWorldIcon } from '@nx/nx-dev-ui-icons';
import { ButtonLink } from './button';
import {
PlayIcon,
XMarkIcon,
ChatBubbleLeftRightIcon,
} from '@heroicons/react/24/outline';
export function LiveStreamNotifier(): ReactElement | null {
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState<boolean>(true);
useEffect(() => {
setIsMounted(true);
const isClosedSession = localStorage.getItem('live-stream-notifier-closed');
if (isClosedSession === 'true') {
setIsVisible(false);
}
}, []);
const closeNotifier = (e: React.MouseEvent) => {
e.stopPropagation();
setIsVisible(false);
localStorage.setItem('live-stream-notifier-closed', 'true');
};
if (!isMounted || !isVisible) return null;
return (
<motion.div
layout
initial={{ y: '120%' }}
animate={{ y: 0 }}
exit={{ y: '120%' }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
mass: 1,
}}
className="fixed bottom-0 left-0 right-0 z-30 w-full overflow-hidden bg-zinc-950 text-white shadow-lg md:bottom-4 md:left-auto md:right-4 md:w-[512px] md:rounded-lg"
style={{ originY: 1 }}
>
<div className="relative p-4">
<button
onClick={closeNotifier}
className="absolute right-2 top-2 rounded-full p-1 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-white"
>
<XMarkIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Close</span>
</button>
<div>
<motion.h3
layout="position"
className="flex items-center gap-2 pr-8 text-lg font-semibold"
>
<MonorepoWorldIcon
aria-hidden="true"
className="size-8 flex-shrink-0"
/>
<span>Monorepo World live replays available!</span>
</motion.h3>
<motion.div key="live-event" className="mt-4 space-y-4">
<p className="mb-2 text-sm">
Watch the replays of exciting talks on developer tooling and
monorepos! Catch all the insightful presentations from the event
on our YouTube channel.
</p>
<div className="flex flex-wrap items-center gap-1 sm:gap-4">
<a
title="Watch track 1"
href="http://go.nx.dev/MWTrack1"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-[#DDFB24] px-2 py-2 text-sm font-semibold text-black transition hover:bg-[#B2CF04] focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:text-black/70 md:px-4"
>
<PlayIcon aria-hidden="true" className="size-4" />
<span>Track 1</span>
</a>
<a
href="http://go.nx.dev/MWTrack2"
target="_blank"
title="Watch track 2"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-[#DDFB24] px-2 py-2 text-sm font-semibold text-black transition hover:bg-[#B2CF04] focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:text-black/70 md:px-4"
>
<PlayIcon aria-hidden="true" className="size-4" />
<span>Track 2</span>
</a>
<ButtonLink
variant="secondary"
size="small"
href="https://discord.gg/7yFabzBP"
target="_blank"
title="Join the discussion on Discord"
rel="noopener noreferrer"
>
<ChatBubbleLeftRightIcon
aria-hidden="true"
className="size-4"
/>
<span>#monorepo-world</span>
</ButtonLink>
</div>
</motion.div>
</div>
</div>
</motion.div>
);
}
+196
View File
@@ -0,0 +1,196 @@
import {
ArrowDownTrayIcon,
ClockIcon,
StarIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
export type PluginType = 'nxOpenSource' | 'nxPowerpack' | 'community';
export interface PluginCardProps {
name: string;
description: string;
url: string;
pluginType: PluginType;
lastPublishedDate?: string;
npmDownloads?: string;
githubStars?: string;
nxVersion?: string;
}
export function PluginCard({
name,
description,
url,
pluginType,
lastPublishedDate,
npmDownloads,
githubStars,
nxVersion,
}: PluginCardProps): JSX.Element {
return (
<div className="focus-within:ring-focus-within:ring-blue-500 relative flex w-full rounded-lg border border-zinc-200 bg-white shadow-xs transition hover:bg-zinc-50 dark:border-zinc-900 dark:bg-zinc-800/60 dark:focus-within:ring-blue-500 dark:hover:bg-zinc-800">
<div className="flex w-full flex-col px-4 py-3">
<h3 className="mb-4 flex items-center leading-tight font-semibold">
<svg
className="mr-3 h-5 w-5"
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>{' '}
<span className="truncate">{name}</span>
</h3>
<Link
href={url}
target={pluginType ? undefined : '_blank'}
rel={pluginType ? undefined : 'noreferrer'}
className="flex grow flex-col focus:outline-none"
prefetch={false}
>
<span className="absolute inset-0" aria-hidden="true" />
<p className="mb-2 line-clamp-3 grow text-sm">{description}</p>
<div className="flex flex-wrap items-center justify-between py-0.5 text-xs font-medium capitalize">
<div className="my-1 mr-1">
<LastPublishedWidget
lastPublishedDate={lastPublishedDate}
></LastPublishedWidget>
</div>
<div className="mx-1 my-1">
<NpmDownloadsWidget
npmDownloads={npmDownloads}
></NpmDownloadsWidget>
</div>
<div className="mx-1 my-1">
<GithubStarsWidget githubStars={githubStars}></GithubStarsWidget>
</div>
<div className="flex flex-grow justify-end">
{pluginType === 'community' ? (
<div className="my-1 ml-1">
<NxVersionWidget nxVersion={nxVersion}></NxVersionWidget>
</div>
) : pluginType === 'nxOpenSource' ? (
<div
data-tooltip="Maintained by the Nx Team"
data-tooltip-align-right
className="my-1 ml-1 inline-block rounded-full border border-green-300 bg-green-50 px-3 py-0.5 text-xs font-medium text-green-600 capitalize dark:border-green-900 dark:bg-green-900/30 dark:text-green-400"
>
Nx Open Source
</div>
) : pluginType === 'nxPowerpack' ? (
<div
data-tooltip="Maintained by the Nx Team"
data-tooltip-align-right
className="my-1 ml-1 inline-block rounded-full border border-green-300 bg-green-50 px-3 py-0.5 text-xs font-medium text-green-600 capitalize dark:border-green-900 dark:bg-green-900/30 dark:text-green-400"
>
Nx Powerpack
</div>
) : undefined}
</div>
</div>
</Link>
</div>
</div>
);
}
export function LastPublishedWidget({
lastPublishedDate,
}: {
lastPublishedDate: string | undefined;
}) {
if (!lastPublishedDate) {
return <div className="w-20"></div>;
}
return (
<abbr data-tooltip="Most Recent Release Date" data-tooltip-align-right>
<ClockIcon className="mx-0.5 inline-block h-4 w-4 align-bottom"></ClockIcon>
{/* yyyy-MM-dd */}
<span>{new Date(lastPublishedDate).toISOString().slice(0, 10)}</span>
<span className="md:hidden">
<br />
<small>Most Recent Release Date</small>
</span>
</abbr>
);
}
function NpmDownloadsWidget({
npmDownloads,
}: {
npmDownloads: string | undefined;
}) {
if (!npmDownloads) {
return <div className="w-8"></div>;
}
return (
<abbr data-tooltip="Monthly NPM Downloads" data-tooltip-align-right>
<ArrowDownTrayIcon className="mx-0.5 inline-block h-4 w-4 align-bottom"></ArrowDownTrayIcon>
{shortenNumber(npmDownloads)}
<span className="md:hidden">
<br />
<small>Monthly NPM Downloads</small>
</span>
</abbr>
);
}
function GithubStarsWidget({
githubStars,
}: {
githubStars: string | undefined;
}) {
if (!githubStars || githubStars == '-1') {
return <div className="w-8"></div>;
}
return (
<abbr data-tooltip="GitHub Stars" data-tooltip-align-right>
<StarIcon className="mx-0.5 inline-block h-4 w-4 align-bottom"></StarIcon>
{shortenNumber(githubStars)}
<span className="md:hidden">
<br />
<small>GitHub Stars</small>
</span>
</abbr>
);
}
function shortenNumber(number: string | number): string {
const num = Number.parseInt(number + '');
if (num > 1000000) {
return Math.floor(num / 1000000) + 'M';
}
if (num > 1000) {
return Math.floor(num / 1000) + 'k';
}
return num + '';
}
function NxVersionWidget({ nxVersion }: { nxVersion: string | undefined }) {
if (!nxVersion) {
return <div className="w-20"></div>;
}
return (
<abbr data-tooltip="Supported Nx Versions" data-tooltip-align-right>
{/* Nx Logo */}
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="mx-0.5 inline-block h-4 w-4 align-bottom"
fill="currentColor"
>
<title>Nx</title>
<path d="M11.987 14.138l-3.132 4.923-5.193-8.427-.012 8.822H0V4.544h3.691l5.247 8.833.005-3.998 3.044 4.759zm.601-5.761c.024-.048 0-3.784.008-3.833h-3.65c.002.059-.005 3.776-.003 3.833h3.645zm5.634 4.134a2.061 2.061 0 0 0-1.969 1.336 1.963 1.963 0 0 1 2.343-.739c.396.161.917.422 1.33.283a2.1 2.1 0 0 0-1.704-.88zm3.39 1.061c-.375-.13-.8-.277-1.109-.681-.06-.08-.116-.17-.176-.265a2.143 2.143 0 0 0-.533-.642c-.294-.216-.68-.322-1.18-.322a2.482 2.482 0 0 0-2.294 1.536 2.325 2.325 0 0 1 4.002.388.75.75 0 0 0 .836.334c.493-.105.46.36 1.203.518v-.133c-.003-.446-.246-.55-.75-.733zm2.024 1.266a.723.723 0 0 0 .347-.638c-.01-2.957-2.41-5.487-5.37-5.487a5.364 5.364 0 0 0-4.487 2.418c-.01-.026-1.522-2.39-1.538-2.418H8.943l3.463 5.423-3.379 5.32h3.54l1.54-2.366 1.568 2.366h3.541l-3.21-5.052a.7.7 0 0 1-.084-.32 2.69 2.69 0 0 1 2.69-2.691h.001c1.488 0 1.736.89 2.057 1.308.634.826 1.9.464 1.9 1.541a.707.707 0 0 0 1.066.596zm.35.133c-.173.372-.56.338-.755.639-.176.271.114.412.114.412s.337.156.538-.311c.104-.231.14-.488.103-.74z" />
</svg>
{nxVersion}
<span className="md:hidden">
<br />
<small>Supported Nx Versions</small>
</span>
</abbr>
);
}
@@ -0,0 +1,116 @@
'use client';
import { Menu } from '@nx/nx-dev-models-menu';
import { Sidebar, SidebarMobile } from './sidebar';
import { useMemo } from 'react';
// TODO(colum): Remove this angular rspack modification once we move angular rspack into main repo (when stable).
const angularRspackSection = {
id: 'angular-rspack',
name: 'angular-rspack',
itemList: [
{
id: 'documents',
path: '/nx-api/angular-rspack/documents',
name: 'documents',
children: [
{
name: 'createConfig',
path: '/nx-api/angular-rspack/documents/create-config',
id: 'create-config',
isExternal: false,
children: [],
disableCollapsible: false,
},
{
name: 'createServer',
path: '/nx-api/angular-rspack/documents/create-server',
id: 'create-server',
isExternal: false,
children: [],
disableCollapsible: false,
},
],
isExternal: false,
disableCollapsible: false,
},
],
hideSectionHeader: false,
};
const angularRsbuildSection = {
id: 'angular-rsbuild',
name: 'angular-rsbuild',
itemList: [
{
id: 'documents',
path: '/nx-api/angular-rsbuild/documents',
name: 'documents',
children: [
{
name: 'createConfig',
path: '/nx-api/angular-rsbuild/documents/create-config',
id: 'create-config',
isExternal: false,
children: [],
disableCollapsible: false,
},
{
name: 'createServer',
path: '/nx-api/angular-rsbuild/documents/create-server',
id: 'create-server',
isExternal: false,
children: [],
disableCollapsible: false,
},
],
isExternal: false,
disableCollapsible: false,
},
],
hideSectionHeader: false,
};
export function SidebarContainer({
menu,
navIsOpen,
toggleNav,
}: {
menu: Menu;
navIsOpen: boolean;
toggleNav: (value: boolean) => void;
}): JSX.Element {
// TODO(colum): Remove this angular-rspack modification once we move angular rspack into main repo (when stable).
const menuWithAngularRspack = useMemo(() => {
const angularIdx = menu.sections.findIndex((s) => s.id === 'angular');
if (angularIdx === -1) {
return menu;
}
const sections = [
...menu.sections.slice(0, angularIdx),
angularRspackSection,
angularRsbuildSection,
...menu.sections.slice(angularIdx),
];
return {
...menu,
sections,
};
}, [menu]);
return (
<div id="sidebar" data-testid="sidebar">
<SidebarMobile
menu={menuWithAngularRspack}
toggleNav={toggleNav}
navIsOpen={navIsOpen}
/>
<div className="hidden h-full w-72 flex-col border-r border-zinc-200 md:flex dark:border-zinc-700 dark:bg-zinc-900">
<div className="relative flex flex-grow overflow-y-scroll p-4">
<Sidebar menu={menuWithAngularRspack} />
</div>
{/*<div className="relative flex flex-col space-y-1 border-t border-zinc-200 px-4 py-2 dark:border-zinc-700">*/}
{/* // another section.*/}
{/*</div>*/}
</div>
</div>
);
}
+506
View File
@@ -0,0 +1,506 @@
'use client';
import {
Dialog,
DialogPanel,
Transition,
TransitionChild,
} from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { AlgoliaSearch } from '@nx/nx-dev-feature-search';
import { Menu, MenuItem, MenuSection } from '@nx/nx-dev-models-menu';
import { iconsMap } from '@nx/nx-dev-ui-references';
import cx from 'classnames';
import Link from 'next/link';
import { useRouter } from 'next/router';
import {
createRef,
Fragment,
type JSX,
useCallback,
useRef,
useState,
useEffect,
} from 'react';
import { NxIcon } from '@nx/nx-dev-ui-icons';
export interface SidebarProps {
menu: Menu;
}
export interface FloatingSidebarProps {
menu: Menu;
navIsOpen: boolean;
toggleNav: (open: boolean) => void;
}
export function Sidebar({ menu }: SidebarProps): JSX.Element {
return (
<div data-testid="navigation-wrapper">
<nav
id="nav"
data-testid="navigation"
className="pb-4 text-base lg:text-sm"
>
{menu.sections.map((section, index) => {
return (
<SidebarSection key={section.id + '-' + index} section={section} />
);
})}
</nav>
</div>
);
}
function SidebarSection({ section }: { section: MenuSection }): JSX.Element {
// Get all items with refs
const itemList = section.itemList.map((i) => ({
...i,
ref: createRef<HTMLDivElement>(),
}));
return (
<>
{section.hideSectionHeader ? null : (
<h4
data-testid={`section-h4:${section.id}`}
className="mb-3 mt-8 border-b border-solid border-zinc-200 pb-2 text-xl font-bold dark:border-zinc-700 dark:text-zinc-100"
>
{section.name}
</h4>
)}
<ul>
<li className="mt-2">
{itemList
.filter((i) => !!i.children?.length)
.map((item, index) => {
// Check if this specific item is the Technologies item
return (
<div key={item.id + '-' + index} ref={item.ref}>
<SidebarSectionItems
key={item.id + '-' + index}
item={item}
isNested={false}
firstLevel={true}
/>
</div>
);
})}
</li>
</ul>
</>
);
}
function withoutAnchors(linkText: string): string {
return linkText?.includes('#')
? linkText.substring(0, linkText.indexOf('#'))
: linkText;
}
function SidebarSectionItems({
item,
isNested,
icon,
firstLevel,
}: {
item: MenuItem;
isNested?: boolean;
icon?: string;
firstLevel?: boolean;
}): JSX.Element {
const router = useRouter();
const initialRender = useRef(true);
const [currentPath, setCurrentPath] = useState<string>('');
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
setCurrentPath(router.asPath);
}, [router.asPath]);
const isActiveLink = isClient
? withoutAnchors(currentPath).startsWith(item.path)
: false;
const [collapsed, setCollapsed] = useState(
!item.disableCollapsible && !isActiveLink
);
const handleCollapseToggle = useCallback(() => {
if (!item.disableCollapsible) {
setCollapsed(!collapsed);
}
}, [collapsed, setCollapsed, item]);
// Update the children mapping to safely handle cases where item.children might be undefined
const children = item.children || [];
return (
<>
<h5
data-testid={`section-h5:${item.id}`}
className={cx(
'group flex items-center py-2',
'-ml-1 px-1',
!isNested
? 'text-base text-zinc-800 lg:text-base dark:text-zinc-200'
: 'text-sm text-zinc-800 lg:text-sm dark:text-zinc-200',
firstLevel ? 'font-semibold' : '',
item.disableCollapsible ? 'cursor-text' : 'cursor-pointer'
)}
onClick={handleCollapseToggle}
>
{icon && (
<div className="mr-1 flex h-5 w-5 flex-shrink-0 items-center justify-center">
<img
className="h-4 w-4 object-cover opacity-100 dark:invert"
loading="lazy"
src={iconsMap[icon || 'nx']}
alt={item.name + ' illustration'}
aria-hidden="true"
/>
</div>
)}
<div className={cx('flex flex-grow items-center justify-between')}>
{item.disableCollapsible ? (
<Link
href={item.path as string}
className="hover:underline"
prefetch={false}
>
{item.name}
</Link>
) : (
<>
<span className={icon ? 'flex-grow' : ''}>{item.name}</span>
<CollapsibleIcon isCollapsed={collapsed} />
</>
)}
</div>
</h5>
<ul className={collapsed ? 'hidden' : ''}>
{children.map((subItem, index) => {
const isActiveLink = isClient
? withoutAnchors(currentPath).startsWith(subItem.path)
: false;
if (isActiveLink && collapsed && initialRender.current) {
handleCollapseToggle();
}
initialRender.current = false;
return (
<li
key={subItem.id + '-' + index}
data-testid={`section-li:${subItem.id}`}
className={cx(
'relative',
item.id !== 'technologies'
? 'border-l border-zinc-300 pl-2 pl-3 transition-colors duration-150 dark:border-zinc-600'
: ''
)}
>
{(subItem.children || []).length ? (
<SidebarSectionItems
item={subItem}
firstLevel={false}
isNested={true}
icon={
item.id === 'technologies'
? getIconKeyForTechnology(subItem.id)
: undefined
}
/>
) : (
<Link
href={subItem.path}
className={cx(
'relative block py-1 text-zinc-500 transition-colors duration-200 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-300'
)}
prefetch={false}
>
<span
className={cx('relative', {
'text-md font-medium text-blue-500 dark:text-blue-500':
isActiveLink,
})}
>
{subItem.name}
</span>
</Link>
)}
</li>
);
})}
</ul>
</>
);
}
function CollapsibleIcon({
isCollapsed,
}: {
isCollapsed: boolean;
}): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={cx(
'w-3.5 text-zinc-600 transition-all dark:text-zinc-400',
!isCollapsed && 'rotate-90 transform'
)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={'M9 5l7 7-7 7'}
/>
</svg>
);
}
export function SidebarMobile({
menu,
navIsOpen,
toggleNav,
}: FloatingSidebarProps): JSX.Element {
const router = useRouter();
const [currentPath, setCurrentPath] = useState<string>('');
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
setCurrentPath(router.asPath);
}, [router.asPath]);
const isCI: boolean = isClient && currentPath.startsWith('/ci');
const isAPI: boolean = isClient && currentPath.startsWith('/nx-api');
const isExtendingNx: boolean =
isClient && currentPath.startsWith('/extending-nx');
const isPlugins: boolean =
isClient && currentPath.startsWith('/plugin-registry');
const isChangelog: boolean = isClient && currentPath.startsWith('/changelog');
const isAiChat: boolean = isClient && currentPath.startsWith('/ai-chat');
const isNx: boolean =
!isCI &&
!isAPI &&
!isExtendingNx &&
!isPlugins &&
!isChangelog &&
!isAiChat;
const sections = {
general: [
{ name: 'Home', href: '/', current: false },
{ name: 'Blog', href: '/blog', current: false },
{ name: 'Resources', href: '/resources', current: false },
{ name: 'Nx Cloud', href: '/nx-cloud', current: false },
{
name: 'Enterprise',
href: '/enterprise',
current: false,
},
],
documentation: [
{
name: 'Nx',
href: '/docs/getting-started/intro',
current: isNx,
},
{
name: 'CI',
href: '/docs/features/ci-features',
current: isCI,
},
{
name: 'Extending Nx',
href: '/docs/extending-nx/intro',
current: isExtendingNx,
},
{
name: 'Plugins',
href: '/docs/plugin-registry',
current: isPlugins,
},
{
name: 'API',
href: '/docs/reference',
current: isAPI,
},
{
name: 'Changelog',
href: '/changelog',
current: isChangelog,
},
{
name: 'AI Chat',
href: '/ai-chat',
current: isAiChat,
},
],
};
return (
<Transition show={navIsOpen} as={Fragment}>
<Dialog as="div" className="relative z-40" onClose={() => void 0}>
<div className="fixed inset-0 z-40 flex">
<TransitionChild
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<DialogPanel className="relative flex w-full flex-col overflow-y-auto bg-white dark:bg-zinc-900">
{/*HEADER*/}
<div className="flex w-full items-center border-b border-zinc-200 bg-zinc-50 p-4 lg:hidden dark:border-zinc-700 dark:bg-zinc-800/60">
{/*CLOSE BUTTON*/}
<button
type="button"
className="flex focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
onClick={() => toggleNav(!navIsOpen)}
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon className="mr-3 h-6 w-6" />
</button>
{/*SEARCH*/}
{/*LOGO*/}
<div className="ml-auto flex items-center">
<Link
href="/"
className="flex flex-grow items-center px-4 text-zinc-900 lg:px-0 dark:text-white"
prefetch={false}
>
<span className="sr-only">Nx</span>
<NxIcon aria-hidden="true" className="h-8 w-8" />
</Link>
</div>
</div>
<div className="p-4">
{/*SECTIONS*/}
<div className="mt-5 divide-y divide-zinc-200">
<div className="grid w-full shrink-0 grid-cols-3 items-center justify-between">
{sections.general.map((section) => (
<Link
key={section.name}
href={section.href}
className={cx(
section.current
? 'text-blue-600 dark:text-blue-500'
: 'hover:text-zinc-900 dark:hover:text-blue-400',
'whitespace-nowrap p-4 text-center text-sm font-medium'
)}
aria-current={section.current ? 'page' : undefined}
prefetch={false}
>
{section.name}
</Link>
))}
</div>
<div className="grid w-full shrink-0 grid-cols-3 items-center justify-between">
{sections.documentation.map((section) => (
<Link
key={section.name}
href={section.href}
className={cx(
section.current
? 'text-blue-600 dark:text-blue-500'
: 'hover:text-zinc-900 dark:hover:text-blue-400',
'whitespace-nowrap p-4 text-center text-sm font-medium'
)}
aria-current={section.current ? 'page' : undefined}
prefetch={false}
>
{section.name}
</Link>
))}
</div>
</div>
{/*SIDEBAR*/}
<div data-testid="mobile-navigation-wrapper" className="mt-8">
<nav
id="mobile-nav"
data-testid="mobile-navigation"
className="text-base lg:text-sm"
>
{menu.sections.map((section, index) => (
<SidebarSection
key={section.id + '-' + index}
section={section}
/>
))}
</nav>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
);
}
const technologyIconMap: Record<string, string> = {
// JavaScript/TypeScript
typescript: 'ts',
js: 'js',
// Angular
angular: 'angular',
'angular-rspack': 'angular-rspack',
'angular-rsbuild': 'angular-rsbuild',
// React
react: 'react',
'react-native': 'react-native',
remix: 'remix',
next: 'next',
expo: 'expo',
// Vue
vue: 'vue',
nuxt: 'nuxt',
// Node
nodejs: 'node',
'node.js': 'node',
node: 'node',
// Java
java: 'java',
gradle: 'gradle',
// Module Federation
'module-federation': 'module-federation',
// Linting
eslint: 'eslint',
'eslint-technology': 'eslint',
// Testing
'test-tools': 'testtools',
cypress: 'cypress',
jest: 'jest',
playwright: 'playwright',
storybook: 'storybook',
detox: 'detox',
'build-tools': 'buildtools',
webpack: 'webpack',
vite: 'vite',
rollup: 'rollup',
esbuild: 'esbuild',
rspack: 'rspack',
rsbuild: 'rsbuild',
};
function getIconKeyForTechnology(idOrName: string): string {
const normalized = idOrName.toLowerCase();
return technologyIconMap[normalized] || 'nx';
}
@@ -0,0 +1,103 @@
import { FC, SVGProps } from 'react';
export const SquareDottedPattern: FC<SVGProps<SVGSVGElement>> = (props) => (
<>
<svg
className="absolute left-full top-12 translate-x-32 transform"
width={404}
height={384}
fill="none"
viewBox="0 0 404 384"
{...props}
>
<defs>
<pattern
id="74b3fd99-0a6f-4271-bef2-e80eeafdf357"
x={0}
y={0}
width={20}
height={20}
patternUnits="userSpaceOnUse"
>
<rect
x={0}
y={0}
width={4}
height={4}
className="text-zinc-200 dark:text-zinc-800"
fill="currentColor"
/>
</pattern>
</defs>
<rect
width={404}
height={384}
fill="url(#74b3fd99-0a6f-4271-bef2-e80eeafdf357)"
/>
</svg>
<svg
className="absolute right-full top-1/2 -translate-x-32 -translate-y-1/2 transform"
width={404}
height={384}
fill="none"
viewBox="0 0 404 384"
>
<defs>
<pattern
id="f210dbf6-a58d-4871-961e-36d5016a0f49"
x={0}
y={0}
width={20}
height={20}
patternUnits="userSpaceOnUse"
>
<rect
x={0}
y={0}
width={4}
height={4}
className="text-zinc-200 dark:text-zinc-800"
fill="currentColor"
/>
</pattern>
</defs>
<rect
width={404}
height={384}
fill="url(#f210dbf6-a58d-4871-961e-36d5016a0f49)"
/>
</svg>
<svg
className="absolute bottom-12 left-full translate-x-32 transform"
width={404}
height={384}
fill="none"
viewBox="0 0 404 384"
>
<defs>
<pattern
id="d3eb07ae-5182-43e6-857d-35c643af9034"
x={0}
y={0}
width={20}
height={20}
patternUnits="userSpaceOnUse"
>
<rect
x={0}
y={0}
width={4}
height={4}
className="text-zinc-200 dark:text-zinc-800"
fill="currentColor"
/>
</pattern>
</defs>
<rect
width={404}
height={384}
fill="url(#d3eb07ae-5182-43e6-857d-35c643af9034)"
/>
</svg>
</>
);
@@ -0,0 +1,36 @@
interface Testimonial {
author: string;
title: string;
content: string;
imageUrl: string;
link: string;
}
export function TestimonialCard({ data }: { data: Testimonial }): JSX.Element {
return (
<figure className="relative flex flex-col-reverse rounded-lg border border-zinc-200 bg-white/40 p-4 text-sm shadow-xs transition focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 hover:bg-white dark:border-zinc-800/40 dark:bg-zinc-800/60 dark:hover:bg-zinc-800">
<blockquote className="mt-4 text-zinc-600 dark:text-zinc-400">
<p>{data.content}</p>
</blockquote>
<figcaption className="flex items-center space-x-4">
<img
src={data.imageUrl}
alt={data.author}
className="h-12 w-12 flex-none rounded-full border border-zinc-200 object-cover dark:border-zinc-800/40"
loading="lazy"
/>
<div className="flex-auto">
<div className="font-semibold text-zinc-500 dark:text-zinc-300">
<a target="_blank" rel="noreferrer" href={data.link}>
<span className="absolute inset-0"></span>
{data.author}
</a>
</div>
<div className="mt-0.5 text-xs text-zinc-400 dark:text-zinc-500">
{data.title}
</div>
</div>
</figcaption>
</figure>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { ChatBubbleLeftEllipsisIcon } from '@heroicons/react/24/outline';
import { SectionHeading } from './typography';
export function Testimonials(): JSX.Element {
return (
<section id="people-are-talking" className="relative">
<header className="mx-auto max-w-2xl text-center">
<SectionHeading as="h2" variant="title">
People are talking
</SectionHeading>
<SectionHeading as="p" variant="subtitle" className="mt-6">
Whether your workspace{' '}
<span className="font-semibold">
has a single project or thousands
</span>
, Nx will keep your CI fast and your workspace maintainable.
</SectionHeading>
</header>
<div className="md:px-62 mx-auto grid max-w-7xl grid-cols-1 items-stretch gap-8 px-4 py-12 md:grid-cols-3 lg:px-8 lg:py-16">
<div className="rounded-xl bg-zinc-50 p-10 dark:bg-zinc-800/60">
<ChatBubbleLeftEllipsisIcon className="h-8 w-8 text-blue-500 dark:text-blue-500" />
<p className="mt-4">
"It made it possible to remove our hand-rolled code and to use a
well-maintained and streamlined solution, increasing performance
while saving us a lot of time and money. The developer experience is
so good."
</p>
<div className="mt-8 font-semibold">Nitin Vericherla</div>
<div className="mt-0.5 text-sm">UI Architect at Synapse Wireless</div>
</div>
<div className="rounded-xl bg-zinc-50 p-10 dark:bg-zinc-800/60">
<ChatBubbleLeftEllipsisIcon className="h-8 w-8 text-blue-500 dark:text-blue-500" />
<p className="mt-4">
"By updating to the latest Lerna version and enabling Nx caching, we
were able to reduce CI build times by ~35% - a great time saving for
a fast-moving company like Sentry with an extremely active
repository."
</p>
<div className="mt-8 font-semibold">Francesco Novy</div>
<div className="mt-0.5 text-sm">
Senior Software Engineer at Sentry
</div>
</div>
<div className="rounded-xl bg-zinc-50 p-10 dark:bg-zinc-800/60">
<ChatBubbleLeftEllipsisIcon className="h-8 w-8 text-blue-500 dark:text-blue-500" />
<p className="mt-4">
"Since we are using NxCloud, we saw our CI times reduced by 83%!
That means our teams are not waiting hours for their PR to be merged
anymore, we reclaimed our productivity and are pretty happy about
it."
</p>
<div className="mt-8 font-semibold">Laurent Delamare</div>
<div className="mt-0.5 text-sm">Senior Engineer at VMware</div>
</div>
</div>
</section>
);
}
+135
View File
@@ -0,0 +1,135 @@
import Link from 'next/link';
import { SectionDescription, SectionHeading } from './typography';
import {
AdobeIcon,
AwsIcon,
CiscoIcon,
CypressIcon,
FedExIcon,
HiltonIcon,
IntelIcon,
MicrosoftIcon,
RedBullIcon,
RxJSIcon,
SentryIcon,
SevenElevenIcon,
ShopifyIcon,
StorybookIcon,
StrapiIcon,
VmwareIcon,
} from '@nx/nx-dev-ui-icons';
export interface TrustedByProps {
utmSource?: string;
utmCampaign?: string;
}
export function TrustedBy({
utmSource = 'homepage',
utmCampaign = 'homepage_links',
}: TrustedByProps): JSX.Element {
return (
<article className="mx-auto max-w-7xl px-6 lg:px-8">
<div className="text-center">
<SectionHeading
as="h2"
variant="subtitle"
id="trusted"
className="scroll-mt-24 font-medium tracking-tight text-zinc-950 sm:text-3xl dark:text-white"
>
Trusted by leading OSS projects and Fortune 500 companies.
</SectionHeading>
<SectionDescription as="p" className="mt-2">
We developed Nx to be modular and incrementally adoptable to meet you
where youre at.
</SectionDescription>
</div>
<div className="relative mt-12">
<div className="grid grid-cols-3 place-items-center items-center gap-6 transition-all sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8">
<MicrosoftIcon
aria-hidden="true"
className="h-10 w-10 text-zinc-950 dark:text-white"
/>
<AwsIcon
aria-hidden="true"
className="h-14 w-14 text-zinc-950 dark:text-white"
/>
<AdobeIcon
aria-hidden="true"
className="h-10 w-10 text-zinc-950 dark:text-white"
/>
<IntelIcon
aria-hidden="true"
className="hidden h-16 w-16 text-zinc-950 sm:block dark:text-white"
/>
<CiscoIcon
aria-hidden="true"
className="hidden h-16 w-16 text-zinc-950 md:block dark:text-white"
/>
<VmwareIcon
aria-hidden="true"
className="hidden h-24 w-24 text-zinc-950 md:block dark:text-white"
/>
<FedExIcon
aria-hidden="true"
className="hidden h-20 w-20 text-zinc-950 md:block dark:text-white"
/>
<HiltonIcon
aria-hidden="true"
className="hidden h-20 w-20 text-zinc-950 md:block dark:text-white"
/>
<SevenElevenIcon
aria-hidden="true"
className="hidden h-10 w-10 text-zinc-950 lg:block dark:text-white"
/>
<RedBullIcon
aria-hidden="true"
className="hidden h-20 w-20 text-zinc-950 lg:block dark:text-white"
/>
<StorybookIcon
aria-hidden="true"
className="h-10 w-10 text-zinc-950 dark:text-white"
/>
<StrapiIcon
aria-hidden="true"
className="hidden h-10 w-10 text-zinc-950 lg:block dark:text-white"
/>
<CypressIcon
aria-hidden="true"
className="hidden h-12 w-12 text-zinc-950 sm:block dark:text-white"
/>
<SentryIcon
aria-hidden="true"
className="h-16 w-16 text-zinc-950 dark:text-white"
/>
<RxJSIcon
aria-hidden="true"
className="hidden h-10 w-10 text-zinc-950 lg:block dark:text-white"
/>
<ShopifyIcon
aria-hidden="true"
className="h-10 w-10 text-zinc-950 dark:text-white"
/>
</div>
<div className="mt-8 text-center">
<Link
href={`/customers?utm_source=${utmSource}&utm_medium=website&utm_campaign=${utmCampaign}&utm_content=our_customers`}
title="Our customers"
prefetch={false}
className="group font-semibold leading-6 text-zinc-950 transition-all duration-200 dark:text-white"
>
Learn about our customers{' '}
<span
aria-hidden="true"
className="inline-block transition group-hover:translate-x-1"
>
</span>
</Link>
</div>
</div>
</article>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useEffect, useRef } from 'react';
declare global {
interface Window {
twttr?: {
widgets: {
createTweet: (
id: string,
el: HTMLElement,
options?: Record<string, string>
) => Promise<HTMLElement | undefined>;
};
};
}
}
function getTweetId(url: string): string | null {
const match = url.match(/status\/(\d+)/);
return match ? match[1] : null;
}
function loadTwitterScript(): Promise<void> {
return new Promise((resolve) => {
if (window.twttr?.widgets) {
resolve();
return;
}
const existing = document.querySelector(
'script[src="https://platform.twitter.com/widgets.js"]'
);
if (existing) {
existing.addEventListener('load', () => resolve());
return;
}
const script = document.createElement('script');
script.src = 'https://platform.twitter.com/widgets.js';
script.async = true;
script.onload = () => resolve();
document.body.appendChild(script);
});
}
export function TweetEmbed({ url }: { url: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const tweetId = getTweetId(url);
if (!tweetId || !containerRef.current) return;
const container = containerRef.current;
let cancelled = false;
container.innerHTML = '';
loadTwitterScript().then(() => {
if (cancelled) return;
window.twttr?.widgets.createTweet(tweetId, container, {
conversation: 'none',
theme: 'dark',
dnt: 'true',
});
});
return () => {
cancelled = true;
};
}, [url]);
return (
<div ref={containerRef} className="not-prose">
<a href={url}>Loading tweet...</a>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Schema } from '@markdoc/markdoc';
import { TweetEmbed } from './tweet-embed';
export const tweet: Schema = {
render: 'Tweet',
attributes: {
url: {
type: 'String',
required: true,
},
},
};
export type TweetProps = { url: string };
export function Tweet(props: TweetProps) {
return <TweetEmbed url={props.url} />;
}
+125
View File
@@ -0,0 +1,125 @@
import { cx } from '@nx/nx-dev-ui-primitives';
import Link from 'next/link';
import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
type AllowedVariants = 'title' | 'display' | 'subtitle';
type Headings = {
as: ElementType;
className?: string;
children: ReactNode | ReactNode[];
id?: string;
variant: AllowedVariants;
};
type Description = {
as: ElementType;
className?: string;
children: ReactNode | ReactNode[];
id?: string;
};
const variants: Record<AllowedVariants, string> = {
title:
'text-3xl font-bold tracking-tight text-zinc-950 dark:text-white sm:text-5xl',
display:
'text-5xl font-semibold tracking-tight text-balance text-zinc-950 dark:text-white sm:text-7xl',
subtitle:
'text-lg font-medium text-pretty sm:text-xl/8 text-zinc-700 dark:text-zinc-300',
};
export function SectionHeading({
as = 'div',
children,
className,
variant,
...rest
}: Headings): JSX.Element {
const Tag = as;
return (
<Tag className={cx(variants[variant], className)} {...rest}>
{children}
</Tag>
);
}
export function SectionDescription({
as = 'div',
children,
className,
...rest
}: Description): JSX.Element {
const Tag = as;
return (
<Tag
className={cx('text-zinc-700 dark:text-zinc-400', className)}
{...rest}
>
{children}
</Tag>
);
}
// TODO: add external link support with `<a>` only.
/**
* Use `<Link>` from NextJs to create an internal link between screens.
* Set `prefetch` to `false` by default.
* @param className
* @param props
*/
export function TextLink({
className,
...props
}: ComponentPropsWithoutRef<typeof Link>) {
return (
<Link
prefetch={false}
{...props}
className={cx(
className,
'font-bold text-blue-600 underline decoration-blue-600/50 transition hover:text-black hover:decoration-blue-600 dark:text-blue-500 dark:decoration-blue-500/50 dark:hover:text-white dark:hover:decoration-blue-500'
)}
/>
);
}
export function TextLinkHighlight({
className,
...props
}: ComponentPropsWithoutRef<typeof Link>) {
return (
<Link
{...props}
className={cx(
className,
'rounded bg-black px-1 py-0.5 font-bold text-white transition hover:bg-blue-600 dark:bg-white dark:text-black dark:hover:bg-blue-500'
)}
/>
);
}
export function Strong({
className,
...props
}: ComponentPropsWithoutRef<'strong'>) {
return (
<strong
{...props}
className={cx(className, 'font-bold text-zinc-950 dark:text-zinc-100')}
/>
);
}
export function Code({
className,
...props
}: React.ComponentPropsWithoutRef<'code'>) {
return (
<code
{...props}
className={cx(
className,
'rounded border border-zinc-950/10 bg-zinc-950/[2.5%] px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem] dark:border-white/20 dark:bg-white/5 dark:text-white'
)}
/>
);
}
@@ -0,0 +1,95 @@
'use client';
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
Transition,
} from '@headlessui/react';
import { ChevronUpDownIcon } from '@heroicons/react/24/solid';
import Link from 'next/link';
import { Fragment, JSX, useState } from 'react';
const versionOptions = [
{
label: 'v21',
value: '',
},
{
label: 'v20',
value: '20',
},
{
label: 'v19',
value: '19',
},
];
export function VersionPicker(): JSX.Element {
const [selected, _] = useState(versionOptions[0]);
return (
<>
<div className="inline-block">
<div className="w-full">
<Listbox value={selected}>
{({ open }) => (
<div className="relative">
<ListboxButton
className={
'focus-visible:ring-opacity-75 relative w-full cursor-pointer rounded-lg border-zinc-200 py-2 pr-6 text-left font-medium focus:outline-none focus-visible:border-blue-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-blue-300 sm:text-sm dark:border-zinc-700'
}
>
<span className="block">{selected.label}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center">
<ChevronUpDownIcon
className="h-5 w-5 text-zinc-500"
aria-hidden="true"
/>
</span>
</ListboxButton>
<Transition
show={open}
as={Fragment}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<ListboxOptions
static
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xs bg-white py-1 pl-0 text-base shadow-md focus:outline-none sm:text-sm dark:bg-zinc-800/90 dark:focus-within:ring-blue-500"
>
{versionOptions.map((item, idx) => (
<ListboxOption
key={idx}
className={() =>
`relative cursor-pointer list-none select-none hover:bg-zinc-50 dark:hover:bg-zinc-800`
}
value={item}
>
{() => (
<Link
href={
item.value
? `https://${item.value}.nx.dev/docs`
: '#'
}
className={'block px-3 py-2 font-medium'}
>
{item.label}
</Link>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</div>
)}
</Listbox>
</div>
</div>
</>
);
}
+70
View File
@@ -0,0 +1,70 @@
import {
Dialog,
DialogPanel,
Transition,
TransitionChild,
} from '@headlessui/react';
import { Fragment, ReactElement } from 'react';
export interface VideoModalProps {
isOpen: boolean;
onClose: () => void;
videoUrl: string;
}
export function VideoModal({
isOpen,
onClose,
videoUrl,
}: VideoModalProps): ReactElement {
const embedUrl = videoUrl.replace('youtu.be/', 'youtube.com/embed/');
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog
as="div"
open={isOpen}
onClose={onClose}
className="relative z-50"
>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/70 backdrop-blur-xs" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="relative w-auto transform overflow-hidden rounded-2xl border border-white/10 bg-black text-left align-middle shadow-xl transition-all focus:outline-none">
<iframe
width="808"
height="454"
src={embedUrl}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="max-w-full"
/>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
@@ -0,0 +1,19 @@
export {
VideoPlayer,
VideoPlayerProvider,
VideoPlayerThumbnail,
VideoPlayerButton,
VideoPlayerOverlay,
VideoPlayerInline,
VideoPlayerModal,
VideoPlayerInlineButton,
} from './video-player';
export type {
VideoPlayerProps,
VideoPlayerThumbnailProps,
VideoPlayerButtonProps,
VideoPlayerModalProps,
VideoPlayerContextValue,
VideoPlayerProviderProps,
VideoPlayerVariant,
} from './video-player.types';
@@ -0,0 +1,726 @@
'use client';
import {
createContext,
ReactElement,
ReactNode,
useContext,
useState,
} from 'react';
import { cx } from '@nx/nx-dev-ui-primitives';
import { MovingBorder } from '@nx/nx-dev-ui-animations';
import { motion } from 'framer-motion';
import { PlayIcon } from '@heroicons/react/24/outline';
import { VideoModal } from '../video-modal';
import { sendCustomEventViaGtm } from '@nx/nx-dev-feature-analytics';
import {
VideoPlayerButtonProps,
VideoPlayerContextValue,
VideoPlayerModalProps,
VideoPlayerProps,
VideoPlayerProviderProps,
VideoPlayerThumbnailProps,
VideoPlayerVariant,
} from './video-player.types';
const VideoPlayerContext = createContext<VideoPlayerContextValue | null>(null);
const useVideoPlayer = () => {
const context = useContext(VideoPlayerContext);
if (!context) {
throw new Error('useVideoPlayer must be used within a VideoPlayer');
}
return context;
};
const VARIANT_STYLES: Record<
VideoPlayerVariant,
{
gradient: string;
textColor: string;
backgroundColor: string;
borderColor: string;
}
> = {
'blue-pink': {
gradient:
'bg-[radial-gradient(var(--blue-500)_40%,transparent_60%)] opacity-[0.8] dark:bg-[radial-gradient(var(--pink-500)_40%,transparent_60%)]',
textColor: 'text-white',
backgroundColor: 'bg-white/10',
borderColor: 'border-zinc-100',
},
'blue-white-spin': {
gradient:
'bg-[conic-gradient(from_90deg_at_50%_50%,#FFFFFF_0%,#3B82F6_50%,#FFFFFF_100%)] dark:bg-[conic-gradient(from_90deg_at_50%_50%,#FFFFFF_0%,#0EA5E9_50%,#FFFFFF_100%)]',
textColor: 'text-zinc-950',
backgroundColor: 'bg-white/70',
borderColor: 'border-zinc-100',
},
};
/**
* Main VideoPlayer component that provides the relative positioning context for video player elements.
*
* @example
* ```tsx
* // Basic modal video player
* <VideoPlayerProvider
* videoUrl="https://youtu.be/example123"
* analytics={{
* event: 'video-click',
* category: 'marketing',
* label: 'hero-video',
* }}
* >
* <VideoPlayer>
* <VideoPlayerThumbnail
* src="/path/to/thumbnail.jpg"
* alt="Video thumbnail"
* width={960}
* height={540}
* />
* <VideoPlayerOverlay>
* <VideoPlayerButton
* variant="blue-pink"
* text={{
* primary: 'Watch the interview',
* secondary: 'Under 3 minutes.',
* }}
* />
* </VideoPlayerOverlay>
* </VideoPlayer>
* <VideoPlayerModal />
* </VideoPlayerProvider>
* ```
*
* @example
* ```tsx
* // Inline video player
* <VideoPlayerProvider
* videoUrl="https://youtu.be/example123"
* analytics={{
* event: 'video-click',
* category: 'marketing',
* label: 'inline-video',
* }}
* >
* <VideoPlayer>
* <VideoPlayerThumbnail
* src="/path/to/thumbnail.jpg"
* alt="Video thumbnail"
* />
* <VideoPlayerOverlay>
* <VideoPlayerInlineButton
* variant="blue-pink"
* text={{
* primary: 'Watch the video',
* secondary: 'See it in action.',
* }}
* />
* </VideoPlayerOverlay>
* <VideoPlayerInline autoplay />
* </VideoPlayer>
* </VideoPlayerProvider>
* ```
*
* @example
* ```tsx
* // Custom composition with additional elements
* <VideoPlayerProvider
* videoUrl="https://youtu.be/example123"
* analytics={{
* event: 'custom-video-click',
* category: 'custom',
* label: 'custom-example',
* }}
* >
* <div className="relative">
* <VideoPlayer>
* <VideoPlayerThumbnail
* src="/path/to/thumbnail.jpg"
* alt="Custom video thumbnail"
* />
* <VideoPlayerOverlay>
* <VideoPlayerButton
* variant="blue-pink"
* text={{
* primary: 'Watch the video',
* secondary: 'Learn more about Nx.',
* }}
* />
* </VideoPlayerOverlay>
* </VideoPlayer>
*
* // Custom overlay content
* <div className="absolute bottom-4 left-4 text-white">
* <h3 className="text-lg font-semibold">Custom Video Title</h3>
* <p className="text-sm opacity-90">Additional context here</p>
* </div>
* </div>
*
* <VideoPlayerModal />
* </VideoPlayerProvider>
* ```
*/
export function VideoPlayer({
children,
className,
}: VideoPlayerProps): ReactElement {
return <div className={cx('relative', className)}>{children}</div>;
}
/**
* VideoPlayerProvider provides context for video state and analytics.
* Must wrap all other VideoPlayer components to provide shared state.
*
* @example
* ```tsx
* <VideoPlayerProvider
* videoUrl="https://youtu.be/example123"
* analytics={{
* event: 'video-click',
* category: 'marketing',
* label: 'hero-video',
* }}
* onPlay={() => console.log('Video started')}
* onClose={() => console.log('Video closed')}
* >
* // VideoPlayer components
* </VideoPlayerProvider>
* ```
*
* @param videoUrl - YouTube video URL
* @param analytics - Optional analytics configuration
* @param onPlay - Optional callback when video starts playing
* @param onClose - Optional callback when video modal closes
* @param children - VideoPlayer components to render
*/
export function VideoPlayerProvider({
videoUrl,
analytics,
onPlay,
onClose,
children,
}: VideoPlayerProviderProps): ReactElement {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const openModal = () => {
if (analytics) {
sendCustomEventViaGtm(
analytics.event,
analytics.category,
analytics.label
);
}
onPlay?.();
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
onClose?.();
};
const startPlaying = () => {
if (analytics) {
sendCustomEventViaGtm(
analytics.event,
analytics.category,
analytics.label
);
}
onPlay?.();
setIsPlaying(true);
};
const stopPlaying = () => {
setIsPlaying(false);
};
const sendAnalytics = analytics
? (event: string, category: string, label: string) => {
sendCustomEventViaGtm(event, category, label);
}
: undefined;
const contextValue: VideoPlayerContextValue = {
videoUrl,
isModalOpen,
isPlaying,
openModal,
closeModal,
startPlaying,
stopPlaying,
sendAnalytics,
};
return (
<VideoPlayerContext.Provider value={contextValue}>
{children}
</VideoPlayerContext.Provider>
);
}
/**
* VideoPlayerThumbnail displays the video thumbnail image.
*
* @example
* ```tsx
* <VideoPlayerThumbnail
* src="/path/to/thumbnail.jpg"
* alt="Video thumbnail"
* width={960}
* height={540}
* className="rounded-xl"
* />
* ```
*
* @param src - Thumbnail image source URL
* @param alt - Alt text for accessibility
* @param width - Image width (default: 960)
* @param height - Image height (default: 540)
* @param className - Additional CSS classes
*/
export function VideoPlayerThumbnail({
src,
alt,
width = 960,
height = 540,
className,
}: VideoPlayerThumbnailProps): ReactElement {
return (
<img
src={src}
alt={alt}
width={width}
height={height}
loading="lazy"
className={cx('relative rounded-xl', className)}
/>
);
}
/**
* VideoPlayerButton renders the play button for modal-based video playback.
*
* @example
* ```tsx
* <VideoPlayerButton
* variant="blue-pink"
* text={{
* primary: 'Watch the interview',
* secondary: 'Under 3 minutes.',
* }}
* onClick={() => console.log('Button clicked')}
* />
* ```
*
* @example
* ```tsx
* // CI variant with spinning gradient
* <VideoPlayerButton
* variant="blue-white-spin"
* text={{
* primary: 'See how Nx Cloud works',
* secondary: 'In under 9 minutes',
* }}
* />
* ```
*
* @param variant - Visual variant: 'blue-pink' or 'blue-white-spin'
* @param text - Button text configuration
* @param onClick - Optional click callback
* @param className - Additional CSS classes
*/
export function VideoPlayerButton({
variant = 'blue-pink',
size = 'md',
text,
onClick,
className,
...props
}: VideoPlayerButtonProps): ReactElement {
const { openModal } = useVideoPlayer();
const styles = VARIANT_STYLES[variant];
const isSmall = size === 'sm';
const parent = {
initial: {
width: isSmall ? 41 : 82,
transition: {
when: 'afterChildren',
},
},
hover: {
width: isSmall ? 148 : 296,
transition: {
duration: 0.125,
type: 'tween',
ease: 'easeOut',
},
},
};
const child = {
initial: {
opacity: 0,
x: -6,
},
hover: {
x: 0,
opacity: 1,
transition: {
duration: 0.015,
type: 'tween',
ease: 'easeOut',
},
},
};
const handleClick = () => {
onClick?.();
openModal();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick();
}
};
return (
<div
className={cx(
'group relative overflow-hidden rounded-full bg-transparent p-[1px] shadow-md',
className
)}
{...props}
>
<div className="absolute inset-0">
{variant === 'blue-white-spin' ? (
<span className="absolute inset-[-1000%] animate-[spin_2s_linear_infinite] bg-[conic-gradient(from_90deg_at_50%_50%,#FFFFFF_0%,#3B82F6_50%,#FFFFFF_100%)] dark:bg-[conic-gradient(from_90deg_at_50%_50%,#FFFFFF_0%,#0EA5E9_50%,#FFFFFF_100%)]" />
) : (
<MovingBorder duration={5000} rx="5%" ry="5%">
<div
className={cx(isSmall ? 'size-10' : 'size-20', styles.gradient)}
/>
</MovingBorder>
)}
</div>
<motion.div
initial="initial"
whileHover="hover"
variants={parent}
className={cx(
'relative isolate flex cursor-pointer items-center justify-center rounded-full antialiased backdrop-blur-xl',
isSmall ? 'size-10 gap-3 border p-3' : 'size-20 gap-6 border-2 p-6',
styles.backgroundColor,
styles.textColor,
styles.borderColor
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
role="button"
aria-label={`${text.primary}. ${text.secondary}`}
>
<PlayIcon
aria-hidden="true"
className={cx(
'absolute',
isSmall ? 'left-3 top-3 size-4' : 'left-6 top-6 size-8'
)}
/>
<motion.div
variants={child}
className={cx(
'absolute',
isSmall ? 'left-10 top-3 w-48' : 'left-20 top-4 w-48'
)}
>
<p className={cx('font-medium', isSmall ? 'text-xs' : 'text-base')}>
{text.primary}
</p>
<p className={cx(isSmall ? 'sr-only' : 'text-xs')}>
{text.secondary}
</p>
</motion.div>
</motion.div>
</div>
);
}
/**
* VideoPlayerOverlay positions content over the thumbnail (typically contains the play button).
*
* @example
* ```tsx
* <VideoPlayerOverlay>
* <VideoPlayerButton
* variant="solutions"
* text={{
* primary: 'Watch the video',
* secondary: 'See it in action.',
* }}
* />
* </VideoPlayerOverlay>
* ```
*
* @example
* ```tsx
* // Custom overlay content
* <VideoPlayerOverlay>
* <div className="flex flex-col items-center gap-4">
* <VideoPlayerButton {...buttonProps} />
* <p className="text-white text-sm">Click to play</p>
* </div>
* </VideoPlayerOverlay>
* ```
*
* @param children - Content to overlay on the thumbnail
*/
export function VideoPlayerOverlay({
children,
}: {
children: ReactNode;
}): ReactElement {
return (
<div className="absolute inset-0 grid h-full w-full items-center justify-center">
{children}
</div>
);
}
/**
* VideoPlayerInline shows inline video when playing (for inline mode).
* Only renders when video is playing, otherwise returns null.
*
* @example
* ```tsx
* <VideoPlayerInline autoplay />
* ```
*
* @example
* ```tsx
* // Without autoplay
* <VideoPlayerInline />
* ```
*
* @param autoplay - Whether to autoplay the video (default: false)
*/
export function VideoPlayerInline({
autoplay = false,
}: {
autoplay?: boolean;
}): ReactElement {
const { videoUrl, isPlaying } = useVideoPlayer();
if (!isPlaying) return null;
const embedUrl = videoUrl.replace('youtu.be/', 'youtube.com/embed/');
return (
<div className="absolute inset-0">
<iframe
src={`${embedUrl}${autoplay ? '?autoplay=1' : ''}`}
title="Video player"
width="100%"
height="100%"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="absolute inset-0 size-full rounded-xl"
/>
</div>
);
}
/**
* VideoPlayerModal shows video in a modal (for modal mode).
*
* @example
* ```tsx
* <VideoPlayerModal />
* ```
*
* @example
* ```tsx
* // With custom close handler
* <VideoPlayerModal onClose={() => console.log('Modal closed')} />
* ```
*
* @param onClose - Optional callback when modal closes
*/
export function VideoPlayerModal({
onClose = () => void 0,
}: VideoPlayerModalProps): ReactElement {
const { videoUrl, isModalOpen, closeModal } = useVideoPlayer();
const handleClose = () => {
closeModal();
onClose?.();
};
return (
<VideoModal
isOpen={isModalOpen}
onClose={handleClose}
videoUrl={videoUrl}
/>
);
}
/**
* VideoPlayerInlineButton renders the play button for inline video playback.
*
* @example
* ```tsx
* <VideoPlayerInlineButton
* variant="blue-pink"
* text={{
* primary: 'Watch the video',
* secondary: 'See it in action.',
* }}
* onClick={() => console.log('Button clicked')}
* />
* ```
*
* @example
* ```tsx
* // CI variant for inline playback
* <VideoPlayerInlineButton
* variant="blue-white-spin"
* text={{
* primary: 'See how it works',
* secondary: 'In under 5 minutes',
* }}
* />
* ```
*
* @param variant - Visual variant: 'blue-pink' or 'blue-white-spin'
* @param text - Button text configuration
* @param onClick - Optional click callback
* @param className - Additional CSS classes
*/
export function VideoPlayerInlineButton({
variant = 'blue-pink',
size = 'md',
text,
onClick,
className,
...props
}: VideoPlayerButtonProps): ReactElement {
const { startPlaying } = useVideoPlayer();
const styles = VARIANT_STYLES[variant];
const isSmall = size === 'sm';
const parent = {
initial: {
width: isSmall ? 41 : 82,
transition: {
when: 'afterChildren',
},
},
hover: {
width: isSmall ? 148 : 296,
transition: {
duration: 0.125,
type: 'tween',
ease: 'easeOut',
},
},
};
const child = {
initial: {
opacity: 0,
x: -6,
},
hover: {
x: 0,
opacity: 1,
transition: {
duration: 0.015,
type: 'tween',
ease: 'easeOut',
},
},
};
const handleClick = () => {
onClick?.();
startPlaying();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleClick();
}
};
return (
<div
className={cx(
'group relative overflow-hidden rounded-full bg-transparent p-[1px] shadow-md',
className
)}
{...props}
>
<div className="absolute inset-0">
{variant === 'blue-white-spin' ? (
<span className="absolute inset-[-1000%] animate-[spin_2s_linear_infinite] bg-[conic-gradient(from_90deg_at_50%_50%,#FFFFFF_0%,#3B82F6_50%,#FFFFFF_100%)] dark:bg-[conic-gradient(from_90deg_at_50%_50%,#FFFFFF_0%,#0EA5E9_50%,#FFFFFF_100%)]" />
) : (
<MovingBorder duration={5000} rx="5%" ry="5%">
<div
className={cx(isSmall ? 'size-10' : 'size-20', styles.gradient)}
/>
</MovingBorder>
)}
</div>
<motion.div
initial="initial"
whileHover="hover"
variants={parent}
className={cx(
cx(
'relative isolate flex cursor-pointer items-center justify-center rounded-full antialiased backdrop-blur-xl',
isSmall ? 'size-10 gap-3 border p-3' : 'size-20 gap-6 border-2 p-6'
),
styles.backgroundColor,
styles.textColor,
styles.borderColor
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={0}
role="button"
aria-label={`${text.primary}. ${text.secondary}`}
>
<PlayIcon
aria-hidden="true"
className={cx(
'absolute',
isSmall ? 'left-3 top-3 size-4' : 'left-6 top-6 size-8'
)}
/>
<motion.div
variants={child}
className={cx(
'absolute',
isSmall ? 'left-10 top-2 w-24' : 'left-20 top-4 w-48'
)}
>
<p className={cx('font-medium', isSmall ? 'text-sm' : 'text-base')}>
{text.primary}
</p>
<p className={cx(isSmall ? 'text-[10px]' : 'text-xs')}>
{text.secondary}
</p>
</motion.div>
</motion.div>
</div>
);
}
@@ -0,0 +1,58 @@
import { ComponentProps, ReactElement, ReactNode } from 'react';
export type VideoPlayerVariant = 'blue-pink' | 'blue-white-spin';
export interface VideoPlayerProps {
children: ReactNode;
className?: string;
}
export interface VideoPlayerThumbnailProps {
src: string;
alt: string;
width?: number;
height?: number;
className?: string;
}
export interface VideoPlayerButtonProps extends ComponentProps<'div'> {
variant?: VideoPlayerVariant;
/**
* Visual size of the button. Defaults to 'md'.
* - 'md': current default sizing
* - 'sm': all dimensions roughly halved
*/
size?: 'md' | 'sm';
text: {
primary: string;
secondary: string;
};
onClick?: () => void;
}
export interface VideoPlayerModalProps {
onClose?: () => void;
}
export interface VideoPlayerContextValue {
videoUrl: string;
isModalOpen: boolean;
isPlaying: boolean;
openModal: () => void;
closeModal: () => void;
startPlaying: () => void;
stopPlaying: () => void;
sendAnalytics?: (event: string, category: string, label: string) => void;
}
export interface VideoPlayerProviderProps {
videoUrl: string;
analytics?: {
event: string;
category: string;
label: string;
};
onPlay?: () => void;
onClose?: () => void;
children: ReactNode;
}
@@ -0,0 +1,159 @@
'use client';
import { MouseEvent, ReactElement, useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import {
MegaphoneIcon,
XMarkIcon,
ArrowTopRightOnSquareIcon,
} from '@heroicons/react/24/outline';
export interface WebinarNotifierProps {
id: string;
title: string;
description: string;
/**
* Optional decorative key-art URL (840x320 @2x, transparent background),
* designed to anchor to the card's top-right corner and crest past its
* top/right edges. Hidden below the md breakpoint.
*/
artwork?: string;
primaryCtaUrl: string;
primaryCtaText: string;
secondaryCtaUrl?: string;
secondaryCtaText?: string;
activeUntil?: string; // e.g. new Date.toISOString() '2025-12-11T13:33:35.695Z'
}
export function WebinarNotifier({
id,
title,
description,
artwork,
primaryCtaUrl,
primaryCtaText,
secondaryCtaUrl,
secondaryCtaText,
activeUntil,
}: WebinarNotifierProps): ReactElement | null {
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState<boolean>(true);
const storageKey = `banner-${id}-dismissed`;
useEffect(() => {
setIsMounted(true);
const isDismissed = localStorage.getItem(storageKey);
if (isDismissed === 'true') {
setIsVisible(false);
}
}, [storageKey]);
const closeNotifier = (e: MouseEvent) => {
e.stopPropagation();
setIsVisible(false);
localStorage.setItem(storageKey, 'true');
};
// Check if banner has expired
if (activeUntil && new Date() > new Date(activeUntil)) return null;
if (!isMounted || !isVisible) return null;
return (
<motion.div
layout
initial={{ y: '120%' }}
animate={{ y: 0 }}
exit={{ y: '120%' }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
mass: 1,
}}
className={`fixed right-0 bottom-0 left-0 z-30 w-full overflow-hidden border border-zinc-200 bg-white text-zinc-900 shadow-lg md:right-4 md:bottom-4 md:left-auto md:w-[512px] md:rounded-lg dark:border-transparent dark:bg-zinc-950 dark:text-white ${
artwork ? 'md:overflow-visible' : ''
}`}
style={{ originY: 1 }}
>
{artwork && (
<>
<img
src={artwork}
alt=""
aria-hidden="true"
className="pointer-events-none absolute -top-7 -right-4 z-0 hidden w-[420px] select-none md:block"
/>
{/* Text-protection scrim: keeps title/description legible over any artwork. */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-[1] hidden bg-gradient-to-r from-white from-40% via-white/90 via-55% to-transparent to-70% md:block md:rounded-lg dark:from-zinc-950 dark:via-zinc-950/90"
/>
</>
)}
<div className="relative z-[2] p-4">
<button
onClick={closeNotifier}
className="absolute top-2 right-2 flex h-9 w-9 cursor-pointer items-center justify-center !rounded-full bg-transparent p-1 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 focus:ring-2 focus:ring-zinc-400 focus:outline-none dark:text-white dark:hover:bg-zinc-800 dark:hover:text-white dark:focus:ring-white"
>
<XMarkIcon className="size-5" aria-hidden="true" />
<span className="sr-only">Close</span>
</button>
<div>
<motion.h3
layout="position"
className="flex items-center gap-2 pr-8 text-lg font-semibold"
>
{!artwork && (
<MegaphoneIcon
aria-hidden="true"
className="size-8 flex-shrink-0"
/>
)}
<span>{title}</span>
</motion.h3>
<motion.div key="banner-content" className="mt-4 space-y-4">
<p
className={`mb-2 text-sm text-zinc-500 dark:text-zinc-300 ${
artwork ? 'md:max-w-[58%]' : ''
}`}
>
{description}
</p>
<div className="flex flex-wrap items-center justify-end gap-1 sm:gap-4">
{secondaryCtaUrl && secondaryCtaText && (
<a
href={secondaryCtaUrl}
target="_blank"
title={secondaryCtaText}
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg border border-zinc-300 bg-zinc-100 px-2 py-2 text-sm font-semibold text-zinc-900 no-underline transition hover:bg-zinc-200 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 md:px-4 dark:border-zinc-700 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700"
>
<ArrowTopRightOnSquareIcon
aria-hidden="true"
className="size-4"
/>
<span>{secondaryCtaText}</span>
</a>
)}
<a
title={primaryCtaText}
href={primaryCtaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-zinc-900 px-2 py-2 text-sm font-semibold text-white no-underline transition hover:bg-zinc-800 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 active:text-white/70 md:px-4 dark:bg-pink-600 dark:hover:bg-pink-700 dark:active:text-black/70"
>
<ArrowTopRightOnSquareIcon
aria-hidden="true"
className="size-4"
/>
<span>{primaryCtaText}</span>
</a>
</div>
</motion.div>
</div>
</div>
</motion.div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { FC, SVGProps } from 'react';
export const XIcon: FC<SVGProps<SVGSVGElement>> = (props) => (
<svg
fill="currentColor"
role="img"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" />
</svg>
);
@@ -0,0 +1,150 @@
import { PlayCircleIcon, PlayIcon } from '@heroicons/react/24/solid';
import { Schema } from '@markdoc/markdoc';
import { cx } from '@nx/nx-dev-ui-primitives';
export const youtube: Schema = {
render: 'YouTube',
attributes: {
src: {
type: 'String',
required: true,
},
title: {
type: 'String',
required: true,
},
width: {
type: 'String',
default: '100%',
},
caption: {
// Added caption attribute here
type: 'String',
required: false, // Not required since it's optional
},
},
};
export function computeVideoID(youtubeURL: string): string | undefined {
let match;
// Check for 'https://www.youtube.com/embed/' format
match = youtubeURL.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]+)/);
if (match && match[1]) {
return match[1];
}
// Check for 'https://www.youtube.com/watch?v=' format
match = youtubeURL.match(/youtube\.com\/watch\?v=([a-zA-Z0-9_-]+)/);
if (match && match[1]) {
return match[1];
}
match = youtubeURL.match(/youtube\.com\/live\/([a-zA-Z0-9_-]+)/);
if (match && match[1]) {
return match[1];
}
// Check for 'https://youtu.be/' format
match = youtubeURL.match(/youtu\.be\/([a-zA-Z0-9_-]+)/);
if (match && match[1]) {
return match[1];
}
return undefined;
}
export function computeThumbnailURL(youtubeURL: string) {
const videoID = computeVideoID(youtubeURL);
if (!videoID) {
throw new Error(
`Could not properly compute the thumbnail URL for ${youtubeURL}`
);
}
return `https://i.ytimg.com/vi_webp/${videoID}/maxresdefault.webp`;
}
export function computeWatchOnYoutubeURL(youtubeURL: string) {
const videoID = computeVideoID(youtubeURL);
if (!videoID) {
throw new Error(
`Could not properly compute the embed URL for ${youtubeURL}`
);
}
return 'https://www.youtube.com/watch?v=' + videoID;
}
export function computeEmbedURL(youtubeURL: string) {
const videoID = computeVideoID(youtubeURL);
if (!videoID) {
throw new Error(
`Could not properly compute the embed URL for ${youtubeURL}`
);
}
return 'https://www.youtube.com/embed/' + videoID;
}
const youtubeIcon = (
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="absolute left-1/2 top-1/2 h-12 w-12 -translate-x-1/2 -translate-y-1/2 transform"
>
<path d="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14c-1.88-.5-9.38-.5-9.38-.5s-7.5 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.87.5 9.38.5 9.38.5s7.5 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.54 15.57V8.43L15.82 12l-6.28 3.57z" />
</svg>
);
export type YouTubeProps = {
title: string;
caption?: string;
src: string;
width?: string;
disableRoundedCorners?: boolean;
imageOnly?: boolean;
};
export function YouTube(props: YouTubeProps): JSX.Element {
return (
<div className="text-center">
{' '}
{/* Center alignment applied to the container */}
{props.imageOnly ? (
<a
href={computeWatchOnYoutubeURL(props.src)}
className="relative block !text-inherit"
>
{youtubeIcon}
<img
src={computeThumbnailURL(props.src)}
alt={props.title}
width={props.width || '100%'}
className={cx(
{
'rounded-lg shadow-lg': !props.disableRoundedCorners,
},
'aspect-video border-2 border-zinc-200 hover:border-zinc-500 dark:border-zinc-700/40 dark:hover:border-zinc-700'
)}
/>
</a>
) : (
<iframe
src={computeEmbedURL(props.src)}
title={props.title}
width={props.width || '100%'}
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; fullscreen"
loading="lazy"
className={cx('aspect-video', {
'rounded-lg shadow-lg': !props.disableRoundedCorners,
})}
/>
)}
{props.caption && (
<p className="mx-auto pt-0 text-zinc-500 md:w-1/2 dark:text-zinc-400">
{props.caption}
</p>
)}
</div>
);
}