diff --git a/docusaurus.config.ts b/docusaurus.config.ts index ca5bfd08d6b..ed51b87e7fb 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -373,6 +373,7 @@ const config: Config = { path: './tutorial', blogSidebarCount: 0, showReadingTime: false, + postsPerPage: 'ALL', }, ], [ diff --git a/i18n/de/code.json b/i18n/de/code.json index e5b58ff1062..51f8b37ec7a 100644 --- a/i18n/de/code.json +++ b/i18n/de/code.json @@ -483,10 +483,10 @@ "message": "Datenschutz" }, "theme.common.sdk.placeholder": { - "message": "Ihr SDK" + "message": "dein SDK" }, "theme.common.connector.placeholder": { - "message": "Ihr Anbieter" + "message": "dein anbieter" }, "theme.common.sdk.native": { "message": "Nativ" diff --git a/i18n/es/code.json b/i18n/es/code.json index b8c8b3d6d0d..4fc76aafe0b 100644 --- a/i18n/es/code.json +++ b/i18n/es/code.json @@ -483,10 +483,10 @@ "message": "Privacidad" }, "theme.common.sdk.placeholder": { - "message": "Tu SDK" + "message": "tu SDK" }, "theme.common.connector.placeholder": { - "message": "Tu proveedor" + "message": "tu proveedor" }, "theme.common.sdk.native": { "message": "Nativo" diff --git a/i18n/fr/code.json b/i18n/fr/code.json index bbc6b582315..d1089f7ea6a 100644 --- a/i18n/fr/code.json +++ b/i18n/fr/code.json @@ -483,10 +483,10 @@ "message": "Confidentialité" }, "theme.common.sdk.placeholder": { - "message": "Votre SDK" + "message": "ton SDK" }, "theme.common.connector.placeholder": { - "message": "Votre fournisseur" + "message": "ton fournisseur" }, "theme.common.sdk.native": { "message": "Natif" diff --git a/i18n/pt-BR/code.json b/i18n/pt-BR/code.json index 908b8822b68..1f4e9970d5e 100644 --- a/i18n/pt-BR/code.json +++ b/i18n/pt-BR/code.json @@ -483,10 +483,10 @@ "message": "Privacidade" }, "theme.common.sdk.placeholder": { - "message": "Seu SDK" + "message": "seu SDK" }, "theme.common.connector.placeholder": { - "message": "Seu provedor" + "message": "seu provedor" }, "theme.common.sdk.native": { "message": "Nativo" diff --git a/plugins/tutorial-generator/utils.ts b/plugins/tutorial-generator/utils.ts index e21fc2dc049..f42e87b049a 100644 --- a/plugins/tutorial-generator/utils.ts +++ b/plugins/tutorial-generator/utils.ts @@ -1,10 +1,10 @@ import { type DocMetadata } from '@docusaurus/plugin-content-docs'; -export const getSdkDisplayName = (sdk: DocMetadata) => - String(sdk.frontMatter.tutorial_name ?? sdk.frontMatter.sidebar_label ?? ''); +export const getSdkDisplayName = (sdk?: DocMetadata) => + String(sdk?.frontMatter.tutorial_name ?? sdk?.frontMatter.sidebar_label ?? ''); -export const getConnectorDisplayName = (connector: DocMetadata) => - String(connector.frontMatter.tutorial_name ?? connector.frontMatter.sidebar_label ?? ''); +export const getConnectorDisplayName = (connector?: DocMetadata) => + String(connector?.frontMatter.tutorial_name ?? connector?.frontMatter.sidebar_label ?? ''); export const getSdkPath = (metadata: DocMetadata) => { const sdkName = String(metadata.frontMatter.tutorial_name ?? ''); diff --git a/src/hooks/use-categorized-tutorial-metadata.ts b/src/hooks/use-categorized-tutorial-metadata.ts index 01df481aa16..ba8f5367d93 100644 --- a/src/hooks/use-categorized-tutorial-metadata.ts +++ b/src/hooks/use-categorized-tutorial-metadata.ts @@ -1,4 +1,5 @@ import { type DocMetadata } from '@docusaurus/plugin-content-docs'; +import { useMemo } from 'react'; import metadata from '@site/tutorial/build-with-logto/metadata.json'; @@ -47,16 +48,19 @@ const useCategorizedTutorialMetadata = () => { { nativeSdks: [], traditionalSdks: [], spaSdks: [] } ); - return { - allSdks: sdks, - allConnectors: [...socialConnectors, ...emailConnectors, ...smsConnectors], - nativeSdks, - traditionalSdks, - spaSdks, - socialConnectors, - emailConnectors, - smsConnectors, - }; + return useMemo( + () => ({ + allSdks: sdks, + allConnectors: [...socialConnectors, ...emailConnectors, ...smsConnectors], + nativeSdks, + traditionalSdks, + spaSdks, + socialConnectors, + emailConnectors, + smsConnectors, + }), + [sdks, socialConnectors, emailConnectors, smsConnectors, nativeSdks, traditionalSdks, spaSdks] + ); }; export default useCategorizedTutorialMetadata; diff --git a/src/theme/BlogLayout/index.module.scss b/src/theme/BlogLayout/index.module.scss new file mode 100644 index 00000000000..9434191bfc3 --- /dev/null +++ b/src/theme/BlogLayout/index.module.scss @@ -0,0 +1,42 @@ +.container { + max-width: 1000px; + width: 100%; + margin: 0 auto; + padding: 0 24px; + + .row { + display: flex; + gap: 24px; + + .main { + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; + overflow: hidden; + margin-bottom: 50px; + } + + .toc { + flex: 0 0 276px; + } + } + + &.hasToc { + max-width: 1200px; + } +} + +@media (max-width: 996px) { + .container { + max-width: 100vw; + + &.hasToc { + max-width: 100vw; + } + + .row .toc { + display: none; + } + } +} diff --git a/src/theme/BlogLayout/index.tsx b/src/theme/BlogLayout/index.tsx index cd5c110191e..2c58e1468aa 100644 --- a/src/theme/BlogLayout/index.tsx +++ b/src/theme/BlogLayout/index.tsx @@ -4,6 +4,8 @@ import Layout from '@theme/Layout'; import clsx from 'clsx'; import ReactModal from 'react-modal'; +import styles from './index.module.scss'; + ReactModal.setAppElement('#__docusaurus'); export default function BlogLayout(props: Props): JSX.Element { @@ -12,18 +14,11 @@ export default function BlogLayout(props: Props): JSX.Element { return ( -
-
- -
- {children} -
- {toc &&
{toc}
} +
+
+ {hasSidebar && } +
{children}
+ {toc &&
{toc}
}
diff --git a/src/theme/BlogListPage/index.module.scss b/src/theme/BlogListPage/index.module.scss new file mode 100644 index 00000000000..419ac2cd317 --- /dev/null +++ b/src/theme/BlogListPage/index.module.scss @@ -0,0 +1,11 @@ +.title { + font: var(--font-headline-1); + margin-block: 32px 0; + text-align: center; +} + +.subtitle { + font: var(--font-body-0); + text-align: center; + margin-bottom: 16px; +} diff --git a/src/theme/BlogListPage/index.tsx b/src/theme/BlogListPage/index.tsx new file mode 100644 index 00000000000..bdbd65413b7 --- /dev/null +++ b/src/theme/BlogListPage/index.tsx @@ -0,0 +1,112 @@ +import Translate from '@docusaurus/Translate'; +import { useHistory, useLocation } from '@docusaurus/router'; +import { PageMetadata, HtmlClassNameProvider, ThemeClassNames } from '@docusaurus/theme-common'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { conditional } from '@silverhand/essentials'; +import BlogLayout from '@theme/BlogLayout'; +import type { Props } from '@theme/BlogListPage'; +import BlogListPageStructuredData from '@theme/BlogListPage/StructuredData'; +import BlogListPaginator from '@theme/BlogListPaginator'; +import BlogPostItems from '@theme/BlogPostItems'; +import SearchMetadata from '@theme/SearchMetadata'; +import clsx from 'clsx'; +import { useMemo } from 'react'; + +import { getConnectorPath, getSdkPath } from '@site/plugins/tutorial-generator/utils'; + +import TitleWithSelectionDropdown from '../BlogPostItem/Header/TitleWithSelectionDropdown'; + +import styles from './index.module.scss'; + +function BlogListPageMetadata(props: Props): JSX.Element { + const { metadata } = props; + const { + siteConfig: { title: siteTitle }, + } = useDocusaurusContext(); + + const { blogDescription, blogTitle, permalink } = metadata; + const isBlogOnlyMode = permalink === '/'; + const title = isBlogOnlyMode ? siteTitle : blogTitle; + return ( + <> + + + + ); +} + +function BlogListPageContent(props: Props): JSX.Element { + const { metadata, items, sidebar } = props; + const { search } = useLocation(); + const { push } = useHistory(); + const searchParams = new URLSearchParams(search); + const sdk = conditional(searchParams.get('sdk')); + const connector = conditional(searchParams.get('connector')); + + const filteredItems = useMemo(() => { + if (!sdk && !connector) { + return items; + } + if (!sdk && connector) { + return items.filter((item) => item.content.metadata.tags[1]?.label === connector); + } + if (sdk && !connector) { + return items.filter((item) => item.content.metadata.tags[2]?.label === sdk); + } + return items.filter( + (item) => + item.content.metadata.tags[1]?.label === connector && + item.content.metadata.tags[2]?.label === sdk + ); + }, [items, sdk, connector]); + + return ( + +

+ { + if (selection) { + searchParams.set('connector', getConnectorPath(selection)); + } else { + searchParams.delete('connector'); + } + push({ + search: searchParams.toString(), + }); + }} + onSelectSdk={(selection) => { + if (selection) { + searchParams.set('sdk', getSdkPath(selection)); + } else { + searchParams.delete('sdk'); + } + push({ + search: searchParams.toString(), + }); + }} + /> +

+

+ + Follow our step-by-step tutorial to set up an authentication system right away. + +

+ + +
+ ); +} + +export default function BlogListPage(props: Props): JSX.Element { + return ( + + + + + + ); +} diff --git a/src/theme/BlogPostItem/Container/index.tsx b/src/theme/BlogPostItem/Container/index.tsx new file mode 100644 index 00000000000..1c0421eddde --- /dev/null +++ b/src/theme/BlogPostItem/Container/index.tsx @@ -0,0 +1,5 @@ +import type { Props } from '@theme/BlogPostItem/Container'; + +export default function BlogPostItemContainer({ children, className }: Props): JSX.Element { + return
{children}
; +} diff --git a/src/theme/BlogPostItem/Footer/index.tsx b/src/theme/BlogPostItem/Footer/index.tsx new file mode 100644 index 00000000000..1ea74fe8196 --- /dev/null +++ b/src/theme/BlogPostItem/Footer/index.tsx @@ -0,0 +1,65 @@ +import { useBlogPost } from '@docusaurus/plugin-content-blog/client'; +import { ThemeClassNames } from '@docusaurus/theme-common'; +import EditMetaRow from '@theme/EditMetaRow'; +import TagsListInline from '@theme/TagsListInline'; +import clsx from 'clsx'; + +export default function BlogPostItemFooter() { + const { metadata, isBlogPostPage } = useBlogPost(); + const { tags, editUrl, hasTruncateMarker, lastUpdatedBy, lastUpdatedAt } = metadata; + + // A post is truncated if it's in the "list view" and it has a truncate marker + const truncatedPost = !isBlogPostPage && hasTruncateMarker; + + const tagsExists = tags.length > 0; + + const renderFooter = tagsExists || truncatedPost || editUrl; + + if (!renderFooter || !isBlogPostPage) { + return null; + } + + // BlogPost footer - details view + const canDisplayEditMetaRow = !!(editUrl || lastUpdatedAt || lastUpdatedBy); + + return ( +
+ {tagsExists && ( +
+
+ +
+
+ )} + {canDisplayEditMetaRow && ( + + )} +
+ ); + + // BlogPost footer - list view - Charles commented out - We don't need tags in list view + // else { + // return ( + //
+ // {tagsExists && ( + //
+ // + //
+ // )} + // {truncatedPost && ( + //
+ // + //
+ // )} + //
+ // ); + // } +} diff --git a/src/theme/BlogPostItem/Header/Info/index.tsx b/src/theme/BlogPostItem/Header/Info/index.tsx index 1438845c20f..6a6342f21cc 100644 --- a/src/theme/BlogPostItem/Header/Info/index.tsx +++ b/src/theme/BlogPostItem/Header/Info/index.tsx @@ -47,13 +47,10 @@ function Spacer() { return <>{' · '}; } -export default function BlogPostItemHeaderInfo({ className }: Props): JSX.Element { +export default function BlogPostItemHeaderInfo({ className }: Props) { const { metadata } = useBlogPost(); const { date, readingTime } = metadata; - // Charles edited this to remove the time from generated "Build X with Y tutorials" - const isTutorial = metadata.frontMatter.slug?.startsWith('how-to-build-'); - const dateTimeFormat = useDateTimeFormat({ day: 'numeric', month: 'long', @@ -61,11 +58,18 @@ export default function BlogPostItemHeaderInfo({ className }: Props): JSX.Elemen timeZone: 'UTC', }); + // Charles edited this to remove the time from generated "Build X with Y tutorials" + const isTutorial = metadata.frontMatter.slug?.startsWith('how-to-build-'); + + if (isTutorial) { + return null; + } + const formatDate = (blogDate: string) => dateTimeFormat.format(new Date(blogDate)); return (
- {!isTutorial && } + {readingTime !== undefined && ( <> diff --git a/src/theme/BlogPostItem/Header/SelectionDropdown/index.module.scss b/src/theme/BlogPostItem/Header/SelectionDropdown/index.module.scss index 7412995f782..83c8e79ee59 100644 --- a/src/theme/BlogPostItem/Header/SelectionDropdown/index.module.scss +++ b/src/theme/BlogPostItem/Header/SelectionDropdown/index.module.scss @@ -55,6 +55,21 @@ } } +.clearButton { + margin: 22px 0 6px; + padding: 0; + height: 20px; + color: var(--logto-link-color); + border: none; + background: transparent; + font: var(--font-body-2); + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + html[data-theme='dark'] { .dropdown { background: var(--logto-container-neutral-bg); diff --git a/src/theme/BlogPostItem/Header/SelectionDropdown/index.tsx b/src/theme/BlogPostItem/Header/SelectionDropdown/index.tsx index 6075b95a023..1b63fa3c9e1 100644 --- a/src/theme/BlogPostItem/Header/SelectionDropdown/index.tsx +++ b/src/theme/BlogPostItem/Header/SelectionDropdown/index.tsx @@ -1,3 +1,4 @@ +import Translate from '@docusaurus/Translate'; import { type DocMetadata } from '@docusaurus/plugin-content-docs'; import { type Nullable } from '@silverhand/essentials'; import { Fragment, type RefObject, useRef } from 'react'; @@ -14,9 +15,17 @@ type DropdownProps = { readonly options: Record; readonly onSelect: (option: { displayName: string; metadata: DocMetadata }) => void; readonly onClose: () => void; + readonly onReset?: () => void; }; -const SelectionDropdown = ({ isOpen, anchorRef, options, onSelect, onClose }: DropdownProps) => { +const SelectionDropdown = ({ + isOpen, + anchorRef, + options, + onSelect, + onClose, + onReset, +}: DropdownProps) => { const overlayRef = useRef(null); const { position, mutate } = usePosition({ @@ -75,6 +84,15 @@ const SelectionDropdown = ({ isOpen, anchorRef, options, onSelect, onClose }: Dr ); })} + {onReset && ( + + )}
); diff --git a/src/theme/BlogPostItem/Header/Title/index.tsx b/src/theme/BlogPostItem/Header/Title/index.tsx index cbd0d192110..e7ff2f8ba48 100644 --- a/src/theme/BlogPostItem/Header/Title/index.tsx +++ b/src/theme/BlogPostItem/Header/Title/index.tsx @@ -1,18 +1,18 @@ import BrowserOnly from '@docusaurus/BrowserOnly'; -import Link from '@docusaurus/Link'; import { useBlogPost } from '@docusaurus/plugin-content-blog/client'; import Admonition from '@theme/Admonition'; import type { Props } from '@theme/BlogPostItem/Header/Title'; import { clsx } from 'clsx'; import { useCallback, useEffect } from 'react'; +import TitleWithHighlights from '../TitleWithHighlights'; import TitleWithSelectionDropdown from '../TitleWithSelectionDropdown'; import styles from './styles.module.css'; const Content = ({ className }: Props): JSX.Element => { const { metadata, isBlogPostPage } = useBlogPost(); - const { permalink, title } = metadata; + const { title } = metadata; const TitleHeading = isBlogPostPage ? 'h1' : 'h2'; const shouldReplace = window.location.pathname.startsWith('/blog'); @@ -73,11 +73,9 @@ const Content = ({ className }: Props): JSX.Element => { itemProp="headline" > {isBlogPostPage ? ( - + ) : ( - - {title} - + )} diff --git a/src/theme/BlogPostItem/Header/Title/styles.module.css b/src/theme/BlogPostItem/Header/Title/styles.module.css index c08d14098f5..64c62c33bd4 100644 --- a/src/theme/BlogPostItem/Header/Title/styles.module.css +++ b/src/theme/BlogPostItem/Header/Title/styles.module.css @@ -6,16 +6,8 @@ /* Make it smaller while in the list */ .listTitle { - font-size: 2rem; - line-height: 50px; -} - -/** - Blog post title should be smaller on smaller devices -**/ -@media (max-width: 576px) { - .listTitle { - font-size: 1.5rem; - line-height: 40px; - } + font: var(--font-body-0); + font-weight: 500; + margin: 0; + color: var(--logto-color-text); } diff --git a/src/theme/BlogPostItem/Header/TitleWithHighlights/index.module.scss b/src/theme/BlogPostItem/Header/TitleWithHighlights/index.module.scss new file mode 100644 index 00000000000..75c7f290c0b --- /dev/null +++ b/src/theme/BlogPostItem/Header/TitleWithHighlights/index.module.scss @@ -0,0 +1,3 @@ +.highlight { + color: var(--logto-link-color); +} diff --git a/src/theme/BlogPostItem/Header/TitleWithHighlights/index.tsx b/src/theme/BlogPostItem/Header/TitleWithHighlights/index.tsx new file mode 100644 index 00000000000..e9e7f02f601 --- /dev/null +++ b/src/theme/BlogPostItem/Header/TitleWithHighlights/index.tsx @@ -0,0 +1,44 @@ +import { type PropBlogPostMetadata } from '@docusaurus/plugin-content-blog'; +import { condString } from '@silverhand/essentials'; + +import styles from './index.module.scss'; + +type Props = { + readonly metadata: PropBlogPostMetadata; +}; + +/** + * Escape potential parentheses in the SDK / connector name. + * The result will be used for the regex that splits the title into parts. + */ +const normalizeName = (name: string) => name.replaceAll('(', '\\(').replaceAll(')', '\\)'); + +const TitleWithHighlights = ({ metadata }: Props) => { + const { frontMatter, title } = metadata; + const sdkName = condString('sdk' in frontMatter && frontMatter.sdk); + const connectorName = condString('connector' in frontMatter && frontMatter.connector); + + const titleParts = title + .split(new RegExp(`(${normalizeName(connectorName)}|${normalizeName(sdkName)})`, 'g')) + .filter(Boolean); + + return titleParts.map((part) => { + if (part === sdkName) { + return ( + + {part} + + ); + } + if (part === connectorName) { + return ( + + {part} + + ); + } + return part; + }); +}; + +export default TitleWithHighlights; diff --git a/src/theme/BlogPostItem/Header/TitleWithSelectionDropdown/index.module.scss b/src/theme/BlogPostItem/Header/TitleWithSelectionDropdown/index.module.scss index 47e46be5048..778c06f017b 100644 --- a/src/theme/BlogPostItem/Header/TitleWithSelectionDropdown/index.module.scss +++ b/src/theme/BlogPostItem/Header/TitleWithSelectionDropdown/index.module.scss @@ -27,20 +27,3 @@ user-select: none; } } - -/* Hide dropdown anchors from title on smaller devices */ -@media (max-width: 576px) { - .dropdownAnchor { - padding: 0; - outline: none; - cursor: default; - - &::after { - display: none; - } - - &.active { - outline: none; - } - } -} diff --git a/src/theme/BlogPostItem/Header/TitleWithSelectionDropdown/index.tsx b/src/theme/BlogPostItem/Header/TitleWithSelectionDropdown/index.tsx index c43a1853f61..eb6ee4df5e3 100644 --- a/src/theme/BlogPostItem/Header/TitleWithSelectionDropdown/index.tsx +++ b/src/theme/BlogPostItem/Header/TitleWithSelectionDropdown/index.tsx @@ -2,6 +2,7 @@ import Translate, { translate } from '@docusaurus/Translate'; import { type PropBlogPostMetadata } from '@docusaurus/plugin-content-blog'; import { type DocMetadata } from '@docusaurus/plugin-content-docs'; import { useHistory } from '@docusaurus/router'; +import { cond, conditional } from '@silverhand/essentials'; import { clsx } from 'clsx'; import { useMemo, useRef, useState } from 'react'; @@ -18,14 +19,18 @@ import Dropdown from '../SelectionDropdown'; import styles from './index.module.scss'; -type Props = { - readonly title: string; +type BlogPostProps = { readonly metadata: PropBlogPostMetadata; - readonly isInListView?: boolean; +}; + +type ListViewProps = { readonly defaultSdkSlugPart?: string; readonly defaultConnectorSlugPart?: string; - readonly onSelectSdk?: (docMetadata: DocMetadata) => void; - readonly onSelectConnector?: (docMetadata: DocMetadata) => void; +}; + +type Props = (BlogPostProps | ListViewProps) & { + readonly onSelectSdk?: (docMetadata?: DocMetadata) => void; + readonly onSelectConnector?: (docMetadata?: DocMetadata) => void; }; type DropdownType = 'sdk' | 'connector'; @@ -34,54 +39,75 @@ const slugFirstPart = 'how-to-build-'; const slugMiddlePart = '-sign-in-with'; const slugLastPart = '-and-logto'; +/* eslint-disable no-template-curly-in-string */ +const sdkTemplateSlot = '${sdk}'; +const connectorTemplateSlot = '${connector}'; +/* eslint-enable no-template-curly-in-string */ + /** - * Escape potential parentheses in the SDK / connector name. + * Escape potential parentheses and dollar signs in the SDK / connector name. * The result will be used for the regex that splits the title into parts. */ -const normalizeName = (name: string) => name.replaceAll('(', '\\(').replaceAll(')', '\\)'); - -const TitleWithSelectionDropdown = ({ - title, - metadata, - isInListView, - defaultSdkSlugPart, - defaultConnectorSlugPart, - onSelectSdk, - onSelectConnector, -}: Props) => { - const history = useHistory(); - const slug = metadata.frontMatter.slug ?? ''; - const sdkName = String(metadata.frontMatter.sdk ?? ''); - const connectorName = String(metadata.frontMatter.connector ?? ''); - const [isDropdownOpen, setIsDropdownOpen] = useState(); +const normalizeName = (name: string) => + name.replaceAll('(', '\\(').replaceAll(')', '\\)').replaceAll('$', '\\$'); - const allTutorialsMetadata = useCategorizedTutorialMetadata(); +const TitleWithSelectionDropdown = (props: Props) => { + const isBlogPost = 'metadata' in props; + const { onSelectSdk, onSelectConnector } = props; + const listViewProps = conditional(!isBlogPost && props); + const blogPostProps = conditional(isBlogPost && props); + const { push } = useHistory(); const sdkNameRef = useRef(null); const connectorNameRef = useRef(null); + const allTutorialsMetadata = useCategorizedTutorialMetadata(); + const [isDropdownOpen, setIsDropdownOpen] = useState(); const defaultSdk = useMemo(() => { - if (!defaultSdkSlugPart) { + if (isBlogPost || !listViewProps?.defaultSdkSlugPart) { return; } - return allTutorialsMetadata.allSdks.find((data) => getSdkPath(data) === defaultSdkSlugPart); - }, [defaultSdkSlugPart, allTutorialsMetadata.allSdks]); + return allTutorialsMetadata.allSdks.find( + (data) => getSdkPath(data) === listViewProps.defaultSdkSlugPart + ); + }, [isBlogPost, listViewProps?.defaultSdkSlugPart, allTutorialsMetadata.allSdks]); const defaultConnector = useMemo(() => { - if (!defaultConnectorSlugPart) { + if (isBlogPost || !listViewProps?.defaultConnectorSlugPart) { return; } return allTutorialsMetadata.allConnectors.find( - (data) => getConnectorPath(data) === defaultConnectorSlugPart + (data) => getConnectorPath(data) === listViewProps.defaultConnectorSlugPart ); - }, [defaultConnectorSlugPart, allTutorialsMetadata.allConnectors]); + }, [isBlogPost, listViewProps?.defaultConnectorSlugPart, allTutorialsMetadata.allConnectors]); + + const sdkName = isBlogPost + ? String(blogPostProps?.metadata.frontMatter.sdk ?? '') + : getSdkDisplayName(defaultSdk); + const connectorName = isBlogPost + ? String(blogPostProps?.metadata.frontMatter.connector ?? '') + : getConnectorDisplayName(defaultConnector); - if (!sdkName && !connectorName) { - return title; + if (blogPostProps && (!sdkName || !connectorName)) { + return blogPostProps.metadata.title; } - const titleParts = title - .split(new RegExp(`(${normalizeName(sdkName)}|${normalizeName(connectorName)})`, 'g')) + const listViewTitle = translate({ + id: 'theme.blog.tutorial.title', + message: `Build ${connectorTemplateSlot} sign-in with ${sdkTemplateSlot}`, + }) + .replace(sdkTemplateSlot, sdkName || sdkTemplateSlot) + .replace(connectorTemplateSlot, connectorName || connectorTemplateSlot); + + const normalizedTitle = blogPostProps?.metadata.title ?? listViewTitle; + + const titleParts = normalizedTitle + .split( + new RegExp( + `(${normalizeName(connectorName || connectorTemplateSlot)}|${normalizeName(sdkName || sdkTemplateSlot)})`, + 'g' + ) + ) .filter(Boolean); const showDropdown = (type: DropdownType) => { @@ -105,7 +131,8 @@ const TitleWithSelectionDropdown = ({ // eslint-disable-next-line @silverhand/fp/no-mutation elementRef.current.textContent = displayName; } - if (!isInListView) { + if (isBlogPost) { + const slug = blogPostProps?.metadata.frontMatter.slug ?? ''; const selectedSlugPart = getPathFn(metadata); const targetSlug = type === 'sdk' @@ -113,19 +140,19 @@ const TitleWithSelectionDropdown = ({ selectedSlugPart + slugLastPart : slugFirstPart + selectedSlugPart + slug.slice(slug.indexOf(slugMiddlePart)); - // eslint-disable-next-line @silverhand/fp/no-mutating-methods - history.push(`/tutorial/${targetSlug}`); + + push(`/tutorial/${targetSlug}`); } }; return ( <> {titleParts.map((part) => { - if (part === sdkName) { + if (part === sdkName || part === sdkTemplateSlot) { return ( - {isInListView ? ( - defaultSdk ? ( - getSdkDisplayName(defaultSdk) - ) : ( - - Your SDK - - ) + {part === sdkTemplateSlot ? ( + + your SDK + ) : ( part )} ); } - if (part === connectorName) { + if (part === connectorName || part === connectorTemplateSlot) { return ( - {isInListView ? ( - defaultConnector ? ( - getConnectorDisplayName(defaultConnector) - ) : ( - - Your provider - - ) + {part === connectorTemplateSlot ? ( + + your provider + ) : ( part )} @@ -201,6 +220,13 @@ const TitleWithSelectionDropdown = ({ onClose={() => { setIsDropdownOpen(undefined); }} + onReset={cond( + !isBlogPost && + (() => { + onSelectSdk?.(undefined); + setIsDropdownOpen(undefined); + }) + )} /> { setIsDropdownOpen(undefined); }} + onReset={cond( + !isBlogPost && + (() => { + onSelectConnector?.(undefined); + setIsDropdownOpen(undefined); + }) + )} /> ); diff --git a/src/theme/BlogPostItem/index.module.scss b/src/theme/BlogPostItem/index.module.scss new file mode 100644 index 00000000000..b5b21d9376e --- /dev/null +++ b/src/theme/BlogPostItem/index.module.scss @@ -0,0 +1,35 @@ +.listViewItemContainer { + display: flex; + align-items: center; + border: 1px solid var(--logto-line-divider); + border-radius: 12px; + box-shadow: var(--logto-shadow-1); + padding: 20px; + width: 100%; + transition: background-color var(--ifm-transition-fast) ease; + + &:hover { + background-color: var(--logto-container-info-bg); + } +} + +.logo { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + margin-inline-end: 12px; + border-radius: 10px; + background: var(--logto-container-neutral-bg); + flex-shrink: 0; + + img { + width: 36px; + height: 36px; + } + + + .logo { + margin-inline-end: 28px; + } +} diff --git a/src/theme/BlogPostItem/index.tsx b/src/theme/BlogPostItem/index.tsx new file mode 100644 index 00000000000..cef0d1ee541 --- /dev/null +++ b/src/theme/BlogPostItem/index.tsx @@ -0,0 +1,98 @@ +import { useBlogPost } from '@docusaurus/plugin-content-blog/client'; +import { type DocMetadata } from '@docusaurus/plugin-content-docs'; +import { condString } from '@silverhand/essentials'; +import type { Props } from '@theme/BlogPostItem'; +import BlogPostItemContainer from '@theme/BlogPostItem/Container'; +import BlogPostItemContent from '@theme/BlogPostItem/Content'; +import BlogPostItemFooter from '@theme/BlogPostItem/Footer'; +import BlogPostItemHeader from '@theme/BlogPostItem/Header'; +import ThemedImage from '@theme/ThemedImage'; +import clsx from 'clsx'; + +import { getConnectorDisplayName, getSdkDisplayName } from '@site/plugins/tutorial-generator/utils'; +import useCategorizedTutorialMetadata from '@site/src/hooks/use-categorized-tutorial-metadata'; +import { useCurrentLocalePrefix } from '@site/src/hooks/useCurrentLocalePrefix'; + +import styles from './index.module.scss'; + +// Apply a bottom margin in list view - Charles commented out +// function useContainerClassName() { +// const {isBlogPostPage} = useBlogPost(); +// return !isBlogPostPage ? 'margin-bottom--xl' : undefined; +// } + +const getLogoFilenames = (locale: string, data?: DocMetadata) => { + const lastSegmentInSlug = data?.slug.slice(data.slug.lastIndexOf('/') + 1) ?? ''; + const logoFilename = condString( + data?.frontMatter.sidebar_custom_props?.logoFilename ?? lastSegmentInSlug + '.svg' + ); + const darkLogoFilename = condString( + data?.frontMatter.sidebar_custom_props?.darkLogoFilename ?? logoFilename + ); + + return { + logoFilename: `${locale}/img/logo/${logoFilename}`, + darkLogoFilename: `${locale}/img/logo/${darkLogoFilename}`, + fallbackLogoFilename: `${locale}/img/logo/broken-image.svg`, + }; +}; + +export default function BlogPostItem({ children, className }: Props): JSX.Element { + const locale = useCurrentLocalePrefix(); + const { isBlogPostPage, frontMatter } = useBlogPost(); + const { allConnectors, allSdks } = useCategorizedTutorialMetadata(); + + const isGeneratedTutorial = frontMatter.slug?.startsWith('how-to-build-'); + const isTutorialListView = isGeneratedTutorial && !isBlogPostPage; + + const blogSdkName = condString('sdk' in frontMatter && frontMatter.sdk); + const blogConnectorName = condString('connector' in frontMatter && frontMatter.connector); + + const connectorMetadata = allConnectors.find( + (data) => getConnectorDisplayName(data) === blogConnectorName + ); + const sdkMetadata = allSdks.find((data) => getSdkDisplayName(data) === blogSdkName); + + const sdkLogos = getLogoFilenames(locale, sdkMetadata); + const connectorLogos = getLogoFilenames(locale, connectorMetadata); + + return ( + + {isTutorialListView && ( + <> +
+ { + // eslint-disable-next-line @silverhand/fp/no-mutation + currentTarget.src = connectorLogos.fallbackLogoFilename; + }} + /> +
+
+ { + // eslint-disable-next-line @silverhand/fp/no-mutation + currentTarget.src = sdkLogos.fallbackLogoFilename; + }} + /> +
+ + )} + + {children} + +
+ ); +} diff --git a/src/theme/BlogPostItems/index.module.scss b/src/theme/BlogPostItems/index.module.scss new file mode 100644 index 00000000000..28d87bd27e2 --- /dev/null +++ b/src/theme/BlogPostItems/index.module.scss @@ -0,0 +1,3 @@ +.link:hover { + text-decoration: none; +} diff --git a/src/theme/BlogPostItems/index.tsx b/src/theme/BlogPostItems/index.tsx new file mode 100644 index 00000000000..9cec965cbe5 --- /dev/null +++ b/src/theme/BlogPostItems/index.tsx @@ -0,0 +1,25 @@ +import Link from '@docusaurus/Link'; +import { BlogPostProvider } from '@docusaurus/plugin-content-blog/client'; +import BlogPostItem from '@theme/BlogPostItem'; +import type { Props } from '@theme/BlogPostItems'; + +import styles from './index.module.scss'; + +export default function BlogPostItems({ + items, + component: BlogPostItemComponent = BlogPostItem, +}: Props): JSX.Element { + return ( + <> + {items.map(({ content: BlogPostContent }) => ( + + + + + + + + ))} + + ); +}