chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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">→</span>
|
||||
</Link>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>© {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>
|
||||
);
|
||||
}
|
||||
@@ -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 auto‑retries, 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 you’re 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user