diff --git a/components/CaseTOC.tsx b/components/CaseTOC.tsx new file mode 100644 index 00000000000..6008a6ea156 --- /dev/null +++ b/components/CaseTOC.tsx @@ -0,0 +1,198 @@ +import { useMemo, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +import { useHeadingsObserver } from './helpers/useHeadingsObserver'; +import ArrowRight from './icons/ArrowRight'; + +interface TocItem { + lvl: number; + content: string; + slug: string; + children?: TocItem[]; +} + +interface TOCItemProps { + item: TocItem; + index: number; + currSelected: string; + closeMenu: () => void; +} + +interface CaseTOCProps { + className: string; + cssBreakingPoint?: 'xl' | 'lg'; + toc: any[]; +} + +/** + * @description Checks if the item is active. + * + * @param {TocItem} item - The TOC item to check. + * @param {string} currSelected - The currently selected TOC item. + * @returns {boolean} - True if the item is active, otherwise false. + */ +const checkIfActive = (item: TocItem, currSelected: string): boolean => { + return item.slug === currSelected || item.children?.some((child) => checkIfActive(child, currSelected)) || false; +}; + +/** + * @description Converts content to TOC items. + * + * @param {any[]} content - The content to convert to TOC items. + * @param {number} level - The level of the TOC item. + * @returns {TocItem[]} - The array of TOC items. + */ +const convertContentToTocItems = (content: any[], level: number = 1): TocItem[] => { + const tocItems = []; + + for (const section of content) { + const item = { + lvl: level, + content: section.title, + slug: section.title + .replace(/<|>|"|\\|\/|=/gi, '') + .replace(/\s/gi, '-') + .toLowerCase() + }; + + if (section.children && section.children.length > 0) { + const children = convertContentToTocItems(section.children, level + 1); + + (item as TocItem).children = children; + } + + tocItems.push(item); + } + + return tocItems; +}; + +/** + * @description Component representing an item in the table of contents (TOC). + * + * @param {TOCItemProps} props - The props for TOCItem. + * @param {TocItem} props.item - The TOC item. + * @param {number} props.index - The index of the TOC item. + * @param {string} props.currSelected - The currently selected TOC item. + * @param {Function} props.closeMenu - A function to close the menu. + */ +function TOCItem({ item, index, currSelected, closeMenu }: TOCItemProps) { + const [open, setOpen] = useState(false); + const active = useMemo(() => checkIfActive(item, currSelected), [item, currSelected]); + + const handleClick = () => { + closeMenu(); + setOpen(false); + }; + + return ( + <> + + {item.children && item.children.length > 0 && ( + + )} + + ); +} + +/** + * @description Component representing a table of contents (TOC) for a case. + * + * @param {CaseTOCProps} props - The props for CaseTOC. + * @param {string} props.className - The CSS class name for the component. + * @param {("xl"|"lg")} [props.cssBreakingPoint="xl"] - The CSS breaking point for responsiveness. + * @param {any[]} props.toc - The table of contents data. + */ +export default function CaseTOC({ className, cssBreakingPoint = 'xl', toc }: CaseTOCProps) { + const { currActive: selected } = useHeadingsObserver(); + const [open, setOpen] = useState(false); + const tocItems = useMemo(() => convertContentToTocItems(toc), [toc]); + + if (!toc || !toc.length) return null; + + return ( +
+
+
+ On this page +
+
setOpen(!open)} + > + +
+
+
+ +
+
+ ); +} diff --git a/components/GeneratorInstallation.tsx b/components/GeneratorInstallation.tsx new file mode 100644 index 00000000000..d280a2199e4 --- /dev/null +++ b/components/GeneratorInstallation.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; + +import { ParagraphTypeStyle } from '@/types/typography/Paragraph'; + +import generatorflagList from '../config/generator-flags.json'; +import generatorTemplates from '../config/generator-templates.json'; +import CodeBlock from './editor/CodeBlock'; +import Select from './form/Select'; +import Paragraph from './typography/Paragraph'; + +interface GeneratorFlagData { + flag: string; + specPath: string; +} + +interface GeneratorFlags { + [key: string]: GeneratorFlagData; +} + +/** + * @description This component displays generator installation options. + */ +export default function GeneratorInstallation() { + const [template, setTemplate] = useState('@asyncapi/html-template'); + // By default we will have output folder flag so its set here. + const [params, setParams] = useState('-o example'); + const [specPath, setSpecPath] = useState('https://bit.ly/asyncapi'); + + const generatorflags = generatorflagList as GeneratorFlags; + + /** + * @description Handles the change event when selecting a generator template. + * @param {string} templateName - The name of the selected template. + */ + function onChangeTemplate(templateName: string) { + setTemplate(templateName); + if (templateName && generatorflags[templateName]) { + const templateBasedJSON = generatorflags[templateName]; + + // options are generated from generator-templates.json + // and flags are fetched from generator-flags.json, + // so it is mandatory to have check in case if any misses the option in future + + if (templateBasedJSON) { + setParams(templateBasedJSON.flag); + setSpecPath(templateBasedJSON.specPath); + } + } + } + + /** + * @description Generates the npm install command. + * @returns {string} The npm install command. + */ + function getNpmCode(): string { + return `npm install -g @asyncapi/cli +asyncapi generate fromTemplate ${specPath} ${template} ${params}`; + } + + /** + * Generates the Docker command. + * @returns {string} The Docker command. + */ + function getDockerCode(): string { + return `docker run --rm -it -v \${PWD}/example:/app/example -v \${PWD}/output:/app/output \\ +asyncapi/cli generate fromTemplate ${specPath} ${template} ${params}`; + } + + return ( +
+
+ + Select a Generator template: + + setName(e.target.value)} + className='form-input block w-full rounded-md sm:text-sm sm:leading-5 md:mt-0 md:flex-1' + required + data-testid='NewsletterSubscribe-text-input' + /> + setEmail(e.target.value)} + className='form-input mt-2 block w-full rounded-md sm:text-sm sm:leading-5 md:mt-0 md:flex-1' + required + data-testid='NewsletterSubscribe-email-input' + /> +
+ ); +} diff --git a/components/Testimonial.tsx b/components/Testimonial.tsx new file mode 100644 index 00000000000..a1dae1c6a28 --- /dev/null +++ b/components/Testimonial.tsx @@ -0,0 +1,50 @@ +import Quote from './icons/Quote'; +import Paragraph from './typography/Paragraph'; + +interface TestimonialProps { + className?: string; + text: string; + authorName: string; + authorDescription: string; + authorAvatar: string; +} + +/** + * @description This component displays Testimonial component. + * + * @param {TestimonialProps} props - The props for the Testimonial component. + * @param {string} props.className - Additional CSS class for styling the card. + * @param {string} props.text - The testimonial from the author. + * @param {string} props.authorName - The name of the author. + * @param {string} props.authorDescription - The description of the author. + * @param {string} props.authorAvatar - The path to avatar of the author. + */ +export default function Testimonial({ + className = '', + text, + authorName, + authorDescription, + authorAvatar +}: TestimonialProps) { + return ( +
  • +
    +
    + + {text} +
    +
    +
    +
    + {authorName} +
    +
    +

    {authorName}

    +

    {authorDescription}

    +
    +
    +
    +
    +
  • + ); +} diff --git a/components/editor/CodeBlock.tsx b/components/editor/CodeBlock.tsx index e38f15e3d16..509b03845c7 100644 --- a/components/editor/CodeBlock.tsx +++ b/components/editor/CodeBlock.tsx @@ -5,7 +5,7 @@ import Caption from '../Caption'; import IconClipboard from '../icons/Clipboard'; interface CodeBlockProps { - children: string; + children?: string; codeBlocks?: { code: string; title?: string; language?: string }[]; className?: string; highlightClassName?: string; @@ -196,7 +196,7 @@ const theme: Theme = { * @param {string} props.title - The title of the code block (default is the specified language). */ export default function CodeBlock({ - children, + children = '', codeBlocks, className = '', highlightClassName = '', diff --git a/components/features/FeatureList.ts b/components/features/FeatureList.ts new file mode 100644 index 00000000000..130cd259952 --- /dev/null +++ b/components/features/FeatureList.ts @@ -0,0 +1,88 @@ +interface FeatureLink { + label: string; + href: string; + id: string; +} + +interface Feature { + id: string; + links: FeatureLink[]; +} + +export const features: Feature[] = [ + { + id: 'specification', + links: [ + { + label: 'Documentation', + href: 'docs/reference/specification/latest', + id: 'whyasyncapi-spec-documentation-link' + } + ] + }, + { + id: 'document-apis', + links: [ + { + label: 'HTML Template', + href: 'https://github.com/asyncapi/html-template', + id: 'whyasyncapi-apis-htmltemplate-link' + }, + { + label: 'React Component', + href: 'https://github.com/asyncapi/asyncapi-react/', + id: 'whyasyncapi-apis-reactcomponents-link' + } + ] + }, + { + id: 'code-generation', + links: [ + { + label: 'Generator', + href: 'tools/generator', + id: 'whyasyncapi-generation-generator-link' + }, + { + label: 'Modelina', + href: 'tools/modelina', + id: 'whyasyncapi-generation-modelina-link' + } + ] + }, + { + id: 'community', + links: [ + { + label: 'Join our Slack', + href: 'https://asyncapi.com/slack-invite', + id: 'whyasyncapi-community-slack-link' + } + ] + }, + { + id: 'open-governance', + links: [ + { + label: 'Read more about Open Governance', + href: 'blog/governance-motivation', + id: 'whyasyncapi-governance-more-link' + }, + { + label: 'TSC Members', + href: 'community/tsc', + id: 'whyasyncapi-governance-tsc-link' + } + ] + }, + { + id: 'much-more', + links: [ + { + label: 'View GitHub Discussions', + href: 'https://github.com/asyncapi/community/discussions', + id: 'whyasyncapi-muchmore-github-link' + } + ] + } +]; diff --git a/components/features/index.tsx b/components/features/index.tsx new file mode 100644 index 00000000000..7c65fe737df --- /dev/null +++ b/components/features/index.tsx @@ -0,0 +1,60 @@ +import Link from 'next/link'; +import React from 'react'; + +import { HeadingLevel, HeadingTypeStyle } from '@/types/typography/Heading'; +import { ParagraphTypeStyle } from '@/types/typography/Paragraph'; + +import { useTranslation } from '../../utils/i18n'; +import Heading from '../typography/Heading'; +import Paragraph from '../typography/Paragraph'; +import TextLink from '../typography/TextLink'; +import { features } from './FeatureList'; + +/** + * @description This component displays AsyncAPI Features in the Hero section of the Home page. + */ +export default function Features() { + const { t } = useTranslation('landing-page'); + + return ( +
    +
    + + {t('features.title')} + + {t('features.description')} +
    +
      + {features.map((feature) => ( +
    • +
      + + {t(`features.${feature.id}.name`)} + + { + + {t(`features.${feature.id}.description`)} + + } +
      +
      + {feature.links.map((link) => ( + + + {t(`features.${feature.id}.links.${link.id}`)} + + + ))} +
      +
    • + ))} +
    +
    +
    +
    + ); +} diff --git a/components/helpers/click-away.ts b/components/helpers/click-away.ts new file mode 100644 index 00000000000..39df9b9d68b --- /dev/null +++ b/components/helpers/click-away.ts @@ -0,0 +1,36 @@ +/** + * @description Registers a callback function to be invoked when a click occurs outside of specified elements. + * @param {function} callback - The callback function to be invoked when a click occurs outside of specified elements. + * It takes a MouseEvent parameter and returns void. + */ +export function registerClickAway(callback: (event: MouseEvent) => void) { + /** + * @description Handles the click event and invokes the callback function if the click occurs outside of specified elements. + * @param {MouseEvent} event - The MouseEvent object representing the click event. + */ + function unregisterClickAway(event: MouseEvent) { + document.removeEventListener('click', unregisterClickAway); + + document.querySelectorAll('iframe').forEach((iframe) => { + const src = iframe.attributes.getNamedItem('src'); + + if (src && src.value.startsWith('/') && !src.value.startsWith('//')) { + iframe.contentWindow?.document.removeEventListener('click', unregisterClickAway); + } + }); + + callback(event); + } + + document.removeEventListener('click', unregisterClickAway); + document.addEventListener('click', unregisterClickAway); + + document.querySelectorAll('iframe').forEach((iframe) => { + const src = iframe.attributes.getNamedItem('src'); + + if (src && src.value.startsWith('/') && !src.value.startsWith('//')) { + iframe.contentWindow?.document.removeEventListener('click', unregisterClickAway); + iframe.contentWindow?.document.addEventListener('click', unregisterClickAway); + } + }); +} diff --git a/components/helpers/useHeadingsObserver.tsx b/components/helpers/useHeadingsObserver.tsx new file mode 100644 index 00000000000..0a340d21633 --- /dev/null +++ b/components/helpers/useHeadingsObserver.tsx @@ -0,0 +1,36 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * @description Custom hook to observe headings and set the current active heading + * @example const { currActive } = useHeadingsObserver(); + * @returns {object} currActive - current active heading + */ +export function useHeadingsObserver() { + const observer = useRef(null); + const headingsRef = useRef | []>([]); + const [currActive, setCurrActive] = useState(null); + + useEffect(() => { + const callback = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setCurrActive(entry.target.id); + } + }); + }; + + // The heading in from top 20% of the viewport to top 30% of the viewport will be considered as active + observer.current = new IntersectionObserver(callback, { + rootMargin: '-20% 0px -70% 0px' + }); + + headingsRef.current = document.querySelectorAll('h2, h3'); + headingsRef.current.forEach((heading) => { + observer.current?.observe(heading); + }); + + return () => observer.current?.disconnect(); + }, []); + + return { currActive }; +} diff --git a/components/icons/Quote.tsx b/components/icons/Quote.tsx new file mode 100644 index 00000000000..aa9f1dd99e5 --- /dev/null +++ b/components/icons/Quote.tsx @@ -0,0 +1,11 @@ +/* eslint-disable max-len */ +/** + * @description Icons for asyncapi website + */ +export default function QuestionMark({ className, ...props }: React.SVGProps) { + return ( + + + + ); +} diff --git a/package-lock.json b/package-lock.json index 4f9b10ced0a..439e915ae59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.11", "@types/react-text-truncate": "^0.14.4", + "@types/react-youtube-embed": "^1.0.4", "@typescript-eslint/eslint-plugin": "^6.19.1", "@typescript-eslint/parser": "^6.19.1", "dedent": "^1.5.1", @@ -2293,6 +2294,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-youtube-embed": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/react-youtube-embed/-/react-youtube-embed-1.0.4.tgz", + "integrity": "sha512-vxTETSo8VjsPixBKapFnLVxggDD+0IWk8hXhStVeml3rIaWA5PS4zFPZnINmVWBDdAWT44416Ac6T+vQTYXXkA==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", diff --git a/package.json b/package.json index 90834a138f1..a583ec70a87 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "@types/react-dom": "^18", "@types/react-syntax-highlighter": "^15.5.11", "@types/react-text-truncate": "^0.14.4", + "@types/react-youtube-embed": "^1.0.4", "@typescript-eslint/eslint-plugin": "^6.19.1", "@typescript-eslint/parser": "^6.19.1", "dedent": "^1.5.1",