diff --git a/src/components/Guides/GuidesCheckbox.tsx b/src/components/Guides/GuidesCheckbox.tsx new file mode 100644 index 0000000000..b231a7c218 --- /dev/null +++ b/src/components/Guides/GuidesCheckbox.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import Icon from '@ably/ui/core/Icon'; +import cn from '@ably/ui/core/utils/cn'; + +const GuidesCheckbox = ({ + label, + name, + value, + disabled = false, + isChecked = false, + handleSelect, + indented = false, +}: { + label: string; + name: string; + value: string; + disabled?: boolean; + isChecked?: boolean; + handleSelect: (e: React.ChangeEvent) => void; + indented?: boolean; +}) => { + return ( +
+ handleSelect(e)} + /> +
+ +
+ +
+ ); +}; + +export default GuidesCheckbox; diff --git a/src/components/Guides/GuidesContent.tsx b/src/components/Guides/GuidesContent.tsx new file mode 100644 index 0000000000..6494901af3 --- /dev/null +++ b/src/components/Guides/GuidesContent.tsx @@ -0,0 +1,101 @@ +import React, { ChangeEvent, useCallback, useEffect, useState } from 'react'; +import { StaticImage } from 'gatsby-plugin-image'; +import GuidesGrid from './GuidesGrid'; +import GuidesFilter from './GuidesFilter'; +import { ImageProps } from '../Image'; +import { guides } from '../../data/guides'; +import { filterSearchGuides, SelectedFilters } from './filter-search-guides'; +import GuidesNoResults from './GuidesNoResults'; +import { useLocation } from '@reach/router'; +import { GuideProduct, AIProvider } from '../../data/guides/types'; +import { products } from '../../data/guides'; + +const GuidesContent = ({ guideImages }: { guideImages: ImageProps[] }) => { + const location = useLocation(); + + // Parse product and provider query parameters + const getInitialFilters = (): SelectedFilters => { + const params = new URLSearchParams(location.search); + const productParam = params.get('product'); + const providerParam = params.get('provider'); + const validProductNames = Object.keys(products); + + const initialProducts: GuideProduct[] = productParam + ? productParam + .split(',') + .map((p) => p.trim()) + .filter((product): product is GuideProduct => validProductNames.includes(product)) + : []; + + const validProviderNames: AIProvider[] = ['anthropic', 'openai']; + const initialProviders: AIProvider[] = providerParam + ? providerParam + .split(',') + .map((p) => p.trim()) + .filter((provider): provider is AIProvider => validProviderNames.includes(provider as AIProvider)) + : []; + + return { + products: initialProducts, + aiProviders: initialProviders, + }; + }; + + const [selected, setSelected] = useState(getInitialFilters); + const [searchTerm, setSearchTerm] = useState(''); + const [filteredGuides, setFilteredGuides] = useState(guides); + + const handleSearch = useCallback((e: ChangeEvent) => { + setSearchTerm(e.target.value); + }, []); + + useEffect(() => { + const filtered = filterSearchGuides(guides, selected, searchTerm); + setFilteredGuides(filtered); + }, [selected, searchTerm]); + + return ( + <> +
+
+

Guides

+

+ In-depth guides to help you build realtime features with Ably and understand how best to architect an app + for your use case. +

+
+
+
+ +
+
+ {filteredGuides.length > 0 ? ( + + ) : ( + + )} +
+
+
+ + + + + + ); +}; + +export default GuidesContent; diff --git a/src/components/Guides/GuidesFilter.tsx b/src/components/Guides/GuidesFilter.tsx new file mode 100644 index 0000000000..0ae92152fc --- /dev/null +++ b/src/components/Guides/GuidesFilter.tsx @@ -0,0 +1,255 @@ +import React, { ChangeEvent, Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; +import Icon from '@ably/ui/core/Icon'; +import { products, aiProviders } from '../../data/guides'; +import Button from '@ably/ui/core/Button'; +import cn from '@ably/ui/core/utils/cn'; +import Badge from '@ably/ui/core/Badge'; +import GuidesCheckbox from './GuidesCheckbox'; +import { SelectedFilters } from './filter-search-guides'; +import { useOnClickOutside } from 'src/hooks/use-on-click-outside'; +import { navigate } from 'gatsby'; +import { GuideProduct, AIProvider } from '../../data/guides/types'; + +const GuidesFilter = ({ + selected, + setSelected, + handleSearch, +}: { + selected: SelectedFilters; + setSelected: Dispatch>; + handleSearch: (e: ChangeEvent) => void; +}) => { + const filterMenuRef = useRef(null); + const [expandFilterMenu, setExpandFilterMenu] = useState(false); + const [localSelected, setLocalSelected] = useState(selected); + + const handleSelectProduct = useCallback((e: ChangeEvent) => { + setLocalSelected((prevSelected) => { + const params = new URLSearchParams(location.search); + + if (e.target.value === 'all') { + params.delete('product'); + params.delete('provider'); + navigate(`${location.pathname}?${params.toString()}`, { replace: true }); + + return { + products: [], + aiProviders: [], + }; + } + + const value = e.target.value as GuideProduct; + const newProducts = prevSelected.products.includes(value) + ? prevSelected.products.filter((item) => item !== value) + : [...prevSelected.products, value]; + + // When AI Transport is toggled, select/deselect all sub-filters + let newAIProviders = prevSelected.aiProviders; + if (value === 'ai-transport') { + if (prevSelected.products.includes('ai-transport')) { + // Deselecting AI Transport - clear all sub-filters + newAIProviders = []; + params.delete('provider'); + } else { + // Selecting AI Transport - select all sub-filters + newAIProviders = ['anthropic', 'langchain', 'openai', 'vercel']; + params.set('provider', newAIProviders.join(',')); + } + } + + if (newProducts.length > 0) { + params.set('product', newProducts.join(',')); + } else { + params.delete('product'); + } + + navigate(`${location.pathname}?${params.toString()}`, { replace: true }); + + return { + products: Array.from(new Set(newProducts)), + aiProviders: newAIProviders, + }; + }); + }, []); + + const handleSelectAIProvider = useCallback((e: ChangeEvent) => { + setLocalSelected((prevSelected) => { + const params = new URLSearchParams(location.search); + const value = e.target.value as AIProvider; + + const newProviders = prevSelected.aiProviders.includes(value) + ? prevSelected.aiProviders.filter((item) => item !== value) + : [...prevSelected.aiProviders, value]; + + // Auto-manage AI Transport selection based on sub-filters + let newProducts = [...prevSelected.products]; + if (newProviders.length > 0 && !prevSelected.products.includes('ai-transport')) { + // If selecting a sub-filter, also select AI Transport + newProducts.push('ai-transport'); + params.set('product', newProducts.join(',')); + } else if (newProviders.length === 0 && prevSelected.products.includes('ai-transport')) { + // If all sub-filters deselected, also deselect AI Transport + newProducts = newProducts.filter((p) => p !== 'ai-transport'); + if (newProducts.length > 0) { + params.set('product', newProducts.join(',')); + } else { + params.delete('product'); + } + } + + if (newProviders.length > 0) { + params.set('provider', newProviders.join(',')); + } else { + params.delete('provider'); + } + + navigate(`${location.pathname}?${params.toString()}`, { replace: true }); + + return { + products: Array.from(new Set(newProducts)), + aiProviders: Array.from(new Set(newProviders)), + }; + }); + }, []); + + const closeFilterMenu = useCallback(() => { + setExpandFilterMenu(false); + setLocalSelected(selected); + }, [selected]); + + useOnClickOutside(closeFilterMenu, filterMenuRef); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 1040) { + setExpandFilterMenu(false); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + if (window.innerWidth >= 1040) { + setSelected(localSelected); + } + }, [expandFilterMenu, localSelected, setSelected]); + + const activeFilters = useMemo( + () => localSelected.products.length + localSelected.aiProviders.length, + [localSelected.products, localSelected.aiProviders], + ); + + const handleApply = () => { + setSelected(localSelected); + setExpandFilterMenu(false); + }; + + return ( + <> +
+ +
+ handleSearch(e)} + /> + + {expandFilterMenu && + ReactDOM.createPortal( +
setExpandFilterMenu(false)} />, + document.body, + )} +
+
+

Filters

+ +
+ + {/* Product filters */} +
+

PRODUCT

+
+ + {Object.entries(products).map(([key, product]) => ( +
+ + {/* AI provider sub-filters - always visible under AI Transport */} + {key === 'ai-transport' && ( +
+ {Object.entries(aiProviders).map(([providerKey, provider]) => ( + + ))} +
+ )} +
+ ))} +
+
+ +
+ +
+
+ + ); +}; + +export default GuidesFilter; diff --git a/src/components/Guides/GuidesGrid.tsx b/src/components/Guides/GuidesGrid.tsx new file mode 100644 index 0000000000..3ddef959c9 --- /dev/null +++ b/src/components/Guides/GuidesGrid.tsx @@ -0,0 +1,95 @@ +import React, { useCallback } from 'react'; +import Badge from '@ably/ui/core/Badge'; +import cn from '@ably/ui/core/utils/cn'; +import { Image, ImageProps } from '../Image'; +import { Guide, GuideProduct } from '../../data/guides/types'; +import { products } from '../../data/guides'; + +const GuidesGrid = ({ + guides = [], + guideImages, + searchTerm, +}: { + guides: Guide[]; + guideImages: ImageProps[]; + searchTerm: string; +}) => { + const displayGuideImage = useCallback((guideImages: ImageProps[], id: string, name: string) => { + const guideImage = guideImages.find((image) => image.name === id); + return guideImage ? {name} : null; + }, []); + + const badgeColorForProduct = useCallback((product: GuideProduct) => { + switch (product) { + case 'chat': + return 'text-violet-400'; + case 'spaces': + return 'text-pink-500'; + case 'liveobjects': + return 'text-green-600'; + case 'ai-transport': + return 'text-blue-600'; + default: + return 'text-orange-700'; + } + }, []); + + const displayProductLabel = useCallback( + (product: GuideProduct) => + products[product] ? ( + + {products[product].label} + + ) : null, + [badgeColorForProduct], + ); + + const highlightSearchTerm = useCallback( + (text: string) => { + if (!searchTerm) { + return text; + } + const searchRegex = new RegExp(`(${searchTerm})`, 'gi'); + + return text.split(searchRegex).map((part, index) => + part.toLowerCase() === searchTerm.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + ); + }, + [searchTerm], + ); + + return ( + + ); +}; + +export default GuidesGrid; diff --git a/src/components/Guides/GuidesList.tsx b/src/components/Guides/GuidesList.tsx new file mode 100644 index 0000000000..ea77edb182 --- /dev/null +++ b/src/components/Guides/GuidesList.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { guides } from '../../data/guides'; +import Link from '../Link'; + +const GuidesList: React.FC = () => { + return ( +
+ {guides.map((guide) => ( + + {guide.name} + + ))} +
+ ); +}; + +export default GuidesList; diff --git a/src/components/Guides/GuidesNoResults.tsx b/src/components/Guides/GuidesNoResults.tsx new file mode 100644 index 0000000000..be7b301d2d --- /dev/null +++ b/src/components/Guides/GuidesNoResults.tsx @@ -0,0 +1,30 @@ +import Badge from '@ably/ui/core/Badge'; +import { Link } from 'gatsby'; + +const GuidesNoResults = () => { + const popularSearchTerms = ['Chat', 'Pub/Sub', 'Authentication', 'Scaling']; + return ( +
+

+ 🫣 +

+

No matching guides found

+

Try these popular search terms

+
+ {popularSearchTerms.map((term) => ( + + {term} + + ))} +
+

+ or{' '} + + Suggest a guide + +

+
+ ); +}; + +export default GuidesNoResults; diff --git a/src/components/Guides/filter-search-guides.ts b/src/components/Guides/filter-search-guides.ts new file mode 100644 index 0000000000..a511bf3e5c --- /dev/null +++ b/src/components/Guides/filter-search-guides.ts @@ -0,0 +1,32 @@ +import { Guide, GuideProduct, AIProvider } from '../../data/guides/types'; + +export type SelectedFilters = { + products: GuideProduct[]; + aiProviders: AIProvider[]; +}; + +export const filterSearchGuides = (guides: Guide[], selected: SelectedFilters, searchTerm: string) => { + const normalizedSearchTerm = searchTerm.toLowerCase(); + + return guides.filter((guide) => { + // Product filter logic + const matchesProduct = + selected.products.length === 0 || guide.products.some((product) => selected.products.includes(product)); + + // AI Provider sub-filter logic (only applies when ai-transport is selected) + const hasAITransportSelected = selected.products.includes('ai-transport'); + const matchesAIProvider = + !hasAITransportSelected || + selected.aiProviders.length === 0 || + (guide.aiProvider && selected.aiProviders.includes(guide.aiProvider)); + + // Search term filter + const matchesSearch = + searchTerm === '' || + guide.name.toLowerCase().includes(normalizedSearchTerm) || + guide.description.toLowerCase().includes(normalizedSearchTerm) || + guide.products.some((product) => product.toLowerCase().includes(normalizedSearchTerm)); + + return matchesProduct && matchesAIProvider && matchesSearch; + }); +}; diff --git a/src/components/Guides/index.ts b/src/components/Guides/index.ts new file mode 100644 index 0000000000..e31bc16619 --- /dev/null +++ b/src/components/Guides/index.ts @@ -0,0 +1,8 @@ +export { default as GuidesContent } from './GuidesContent'; +export { default as GuidesFilter } from './GuidesFilter'; +export { default as GuidesGrid } from './GuidesGrid'; +export { default as GuidesCheckbox } from './GuidesCheckbox'; +export { default as GuidesNoResults } from './GuidesNoResults'; +export { default as GuidesList } from './GuidesList'; +export { filterSearchGuides } from './filter-search-guides'; +export type { SelectedFilters } from './filter-search-guides'; diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index f727b40413..48261b61d2 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -13,6 +13,7 @@ import { IconName } from '@ably/ui/core/Icon/types'; import LeftSidebar from './LeftSidebar'; import UserContext from 'src/contexts/user-context'; import ExamplesList from '../Examples/ExamplesList'; +import { GuidesList } from '../Guides'; import Link from '../Link'; import { InkeepSearchBar } from '../SearchBar/InkeepSearchBar'; import { secondaryButtonClassName, iconButtonClassName, tooltipContentClassName } from './utils/styles'; @@ -30,9 +31,12 @@ const desktopTabs = [ Examples , + + Guides + , ]; -const mobileTabs = ['Documentation', 'Examples']; +const mobileTabs = ['Documentation', 'Examples', 'Guides']; const helpResourcesItems = [ { @@ -167,7 +171,11 @@ const Header: React.FC = () => { options={{ underline: false, flexibleTabHeight: true, - defaultTabIndex: location.pathname.includes('/examples') ? 1 : 0, + defaultTabIndex: location.pathname.includes('/guides') + ? 2 + : location.pathname.includes('/examples') + ? 1 + : 0, }} /> {isMobileMenuOpen && ( @@ -185,6 +193,7 @@ const Header: React.FC = () => { contents={[ , , + , ]} rootClassName="h-full overflow-y-hidden min-h-[3.1875rem] flex flex-col" contentClassName="h-full overflow-y-scroll" diff --git a/src/data/guides/index.ts b/src/data/guides/index.ts new file mode 100644 index 0000000000..545e0e5c70 --- /dev/null +++ b/src/data/guides/index.ts @@ -0,0 +1,48 @@ +import { Guide, GuideProduct, AIProvider } from './types'; + +export const guides: Guide[] = [ + { + id: 'build-livestream-chat', + name: 'Livestream chat', + description: 'Learn how to architect a Livestream chat application to perform at any scale.', + link: '/docs/guides/chat/build-livestream', + products: ['chat'], + }, +]; + +export const products: Record< + GuideProduct, + { + label: string; + subFilters?: Record; + } +> = { + pubsub: { + label: 'Pub/Sub', + }, + chat: { + label: 'Chat', + }, + spaces: { + label: 'Spaces', + }, + liveobjects: { + label: 'LiveObjects', + }, + 'ai-transport': { + label: 'AI Transport', + subFilters: { + anthropic: { label: 'Anthropic' }, + langchain: { label: 'LangChain' }, + openai: { label: 'OpenAI' }, + vercel: { label: 'Vercel AI' }, + }, + }, +}; + +export const aiProviders: Record = { + anthropic: { label: 'Anthropic' }, + langchain: { label: 'LangChain' }, + openai: { label: 'OpenAI' }, + vercel: { label: 'Vercel AI' }, +}; diff --git a/src/data/guides/types.ts b/src/data/guides/types.ts new file mode 100644 index 0000000000..03de0801b5 --- /dev/null +++ b/src/data/guides/types.ts @@ -0,0 +1,11 @@ +export type GuideProduct = 'pubsub' | 'chat' | 'spaces' | 'liveobjects' | 'ai-transport'; +export type AIProvider = 'anthropic' | 'langchain' | 'openai' | 'vercel'; + +export type Guide = { + id: string; + name: string; + description: string; + link: string; + products: GuideProduct[]; + aiProvider?: AIProvider; +}; diff --git a/src/pages/guides/index.tsx b/src/pages/guides/index.tsx new file mode 100644 index 0000000000..00d92cdfb6 --- /dev/null +++ b/src/pages/guides/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { graphql } from 'gatsby'; +import { ImageProps } from '../../components/Image'; +import { Head } from '../../components/Head'; +import { GuidesContent } from '../../components/Guides'; +import { useSiteMetadata } from '../../hooks/use-site-metadata'; + +const Guides = ({ + data: { + allFile: { images }, + }, +}: { + data: { allFile: { images: ImageProps[] } }; +}) => { + const { canonicalUrl } = useSiteMetadata(); + const canonical = canonicalUrl('/guides'); + const meta_title = 'Ably Guides'; + const meta_description = + "Browse Ably's collection of in-depth guides. See how best to architect an app for your use case, or see which features you can build with Ably's APIs."; + + return ( + <> + + + + ); +}; + +export default Guides; + +export const query = graphql` + query { + allFile(filter: { relativeDirectory: { eq: "guides" } }) { + images: nodes { + name + extension + base + publicURL + childImageSharp { + gatsbyImageData + } + } + } + } +`;