diff --git a/components/BlogPost.tsx b/components/BlogPost.tsx new file mode 100644 index 0000000..f4090ec --- /dev/null +++ b/components/BlogPost.tsx @@ -0,0 +1,53 @@ +import Head from 'next/head'; +import React, { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { PageType, setPageType, setPostTitle } from '../state/navigation'; +import { IPost } from '../types/cms'; +import { generateTitle } from '../utils/metadata'; + +import { Article } from '../components/article/Article'; + +// Parallax on bg as mouse moves +export default function BlogPost({ post, url }: { post: IPost; url: string }) { + const dispatch = useDispatch(); + const pageTitle = generateTitle(post?.title); + const imageURL = post?.featureImage?.imageUrl; + + useEffect(() => { + if (post) { + dispatch(setPageType(PageType.POST)); + dispatch(setPostTitle(post.title)); + } + }, []); + + return ( + <> + + {pageTitle} + + + + + + + + + + {' '} + + + + + + +
+
+
+ + ); +} diff --git a/components/Pagination.tsx b/components/Pagination.tsx new file mode 100644 index 0000000..3f26bca --- /dev/null +++ b/components/Pagination.tsx @@ -0,0 +1,45 @@ +import { useContext, ReactElement } from 'react'; +import ReactPaginate from 'react-paginate'; + +import { ScreenContext } from '../contexts/screen'; +import { Contained } from '../components/Contained'; + +interface Props { + currentPage: number; + pageCount: number; + paginationHandler: (page: any) => void; +} + +export default function Pagination(props: Props): ReactElement { + const { currentPage, pageCount, paginationHandler } = props; + const { isMobile } = useContext(ScreenContext); + + // Mobile Pagination Settings + const MAX_PAGINATION_BUTTONS_MOBILE = 5; // On mobile, we want to only have a maximum of 5 pagination buttons + const hasTooManyButtons = + isMobile && pageCount > MAX_PAGINATION_BUTTONS_MOBILE; // Check if the maximum number of pagination buttons have been reached + const EDGE_PAGES = [1, 2, pageCount - 1, pageCount]; // Check if the current page is an edge page, to keep the number of pagination buttons consistent + const isEdgePage = isMobile && EDGE_PAGES.includes(currentPage); + + return ( + +
+
+ '} + breakLabel={'...'} + breakClassName={'break-me'} + activeClassName={'active bg-secondary'} + containerClassName={'pagination bg-primary text-white'} + initialPage={currentPage - 1} + pageCount={pageCount} + marginPagesDisplayed={hasTooManyButtons && !isEdgePage ? 1 : 2} + pageRangeDisplayed={isMobile ? 1 : 2} + onPageChange={paginationHandler} + /> +
+
+
+ ); +} diff --git a/components/RichBody.tsx b/components/RichBody.tsx index 6913910..8bb65c0 100644 --- a/components/RichBody.tsx +++ b/components/RichBody.tsx @@ -76,16 +76,33 @@ export function RichBody(props: Props): ReactElement { // const children; const plaintext = documentToPlainTextString(node); const isShortcode = CMS.SHORTCODE_REGEX.test(plaintext); - - return isShortcode ? ( - renderShortcode(plaintext) - ) : ( -

- {children} -

- ); + if (isShortcode) { + return renderShortcode(plaintext); + } else { + let hasImage = false; + Children.map(children, (child: any) => { + if (child.type === 'figure') { + hasImage = true; + return; + } + }); + if (hasImage) { + return ( + + {children} + + ); + } + return ( +

+ {children} +

+ ); + } }, [BLOCKS.HEADING_1]: (node, children) => (

{ + dispatch(setPageType(PageType.NORMAL)); + }, []); + + return ( + <> + + {pageTitle} + + + + + + + + + + + + + + +
+
+ {page?.hero?.description +
+ + +

+ {page?.title} +

+ +
+ +
+
+
+ + ); +} diff --git a/components/TagBlock.tsx b/components/TagBlock.tsx index 1f3a907..daffe4e 100644 --- a/components/TagBlock.tsx +++ b/components/TagBlock.tsx @@ -4,11 +4,12 @@ import React from 'react'; interface Props { tag: string; size?: 'small' | 'medium' | 'large'; + classes?: string; } export function TagBlock(props: Props) { - const { tag, size = 'small' } = props; - const href = `/blog?tag=${tag.toLowerCase()}`; + const { tag, size = 'small', classes } = props; + const href = `/tag/${tag.toLowerCase()}`; return ( {tag.toLowerCase()} diff --git a/components/navigation/SideMenu.tsx b/components/navigation/SideMenu.tsx index f5ea649..67b785b 100644 --- a/components/navigation/SideMenu.tsx +++ b/components/navigation/SideMenu.tsx @@ -11,6 +11,7 @@ export interface ISideMenuItem { label: string; href: string; isExternal?: boolean; + hasOwnRoute?: boolean; shouldHide?: boolean; } diff --git a/constants/navigation.ts b/constants/navigation.ts index 7e2de89..a47cdbe 100644 --- a/constants/navigation.ts +++ b/constants/navigation.ts @@ -61,11 +61,13 @@ const SIDE_MENU_ITEMS = { id: 8, label: "Oxen's 2021 roadmap", href: '/roadmap', + hasOwnRoute: true, }, [SideMenuItem.FAQ]: { id: 9, label: 'FAQ', href: '/faq', + hasOwnRoute: true, }, } as { [name: string]: ISideMenuItem }; @@ -79,7 +81,7 @@ const MENU_ITEMS: IMenuItem[] = [ }, { label: 'Dev Updates', - href: '/blog?tag=dev-update', + href: '/tag/dev-update', newTab: false, subtle: true, external: false, @@ -125,7 +127,7 @@ const MENU_ITEMS: IMenuItem[] = [ const NAVIGATION = { MENU_ITEMS, SIDE_MENU_ITEMS, - BLOG_REGEX: /^\/(blog)([?tag=[\w-]*)?([?&]page=[0-9]{1,3})?/, + BLOG_REGEX: /^\/(blog|tag)/, POST_REGEX: /^\/(blog\/)(([\w-]{1,100})|(\[slug\]))$/, }; diff --git a/next.config.js b/next.config.js index 8d2ac4e..9a131bc 100644 --- a/next.config.js +++ b/next.config.js @@ -41,10 +41,15 @@ const nextConfig = { destination: '/api/feed/rss', }, { - // The /:slug part is a generic parameter handler to catch all other cases source: '/feed/:slug', destination: '/api/feed/:slug', }, + // Redirects blog posts i.e. https://oxen.io/blog/hello or https://oxen.io/blog/hello-world + // Ignores page results i.e. https://oxen.io/blog/1 + { + source: '/blog/:slug((?:[\\w]{1,}[\\-]{1,}).*|[\\D]{1,})', + destination: '/:slug', + }, ]; }, }; diff --git a/package.json b/package.json index d6247e8..b7653da 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ }, "dependencies": { "@ant-design/icons": "^4.2.2", - "@contentful/rich-text-html-renderer": "^15.0.0", - "@contentful/rich-text-plain-text-renderer": "^15.0.0", - "@contentful/rich-text-react-renderer": "^15.0.0", + "@contentful/rich-text-html-renderer": "^15.3.5", + "@contentful/rich-text-plain-text-renderer": "^15.3.5", + "@contentful/rich-text-react-renderer": "^15.3.5", "@tailwindcss/aspect-ratio": "^0.2.0", "@types/jest": "^26.0.20", "@types/lodash": "^4.14.161", @@ -64,7 +64,7 @@ "@babel/core": "^7.12.17", "@babel/preset-env": "^7.12.17", "@babel/preset-react": "^7.12.13", - "@contentful/rich-text-types": "^15.0.0", + "@contentful/rich-text-types": "^15.3.5", "@tailwindcss/forms": "^0.3.3", "@types/base-64": "^1.0.0", "@types/lodash.get": "^4.4.6", diff --git a/pages/[page].tsx b/pages/[page].tsx index 61a5e89..9518196 100644 --- a/pages/[page].tsx +++ b/pages/[page].tsx @@ -1,111 +1,94 @@ -// [slug].js -import Head from 'next/head'; -import React, { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Contained } from '../components/Contained'; -import { RichBody } from '../components/RichBody'; +import { GetStaticPaths } from 'next'; + import { CMS, NAVIGATION } from '../constants'; -import { CmsApi, unslugify } from '../services/cms'; -import { PageType, setPageType, SideMenuItem } from '../state/navigation'; -import { ISplitPage } from '../types/cms'; -import { generateTitle, generateURL } from '../utils/metadata'; +import { CmsApi, generateLinkMeta, unslugify } from '../services/cms'; +import { SideMenuItem } from '../state/navigation'; +import { IPath } from '../types'; +import { IPost, ISplitPage, isPost } from '../types/cms'; +import { isLocal } from '../utils/links'; -interface IPath { - params: { page: string }; -} +import BlogPost from '../components/BlogPost'; +import RichPage from '../components/RichPage'; -export async function getStaticPaths() { - // Get paths to all pages - // Hardcoded in navigation constants. - // Contentful can edit entries but cannot add/remove - // without touching code. - const paths: IPath[] = Object.values(NAVIGATION.SIDE_MENU_ITEMS).map( - item => ({ - params: { page: item.href }, - }), - ); +export const getStaticPaths: GetStaticPaths = async () => { + // Get paths to all pages stored in navigation constants. + // Contentful can edit entries but cannot add/remove without touching code. + const navigationPaths: IPath[] = Object.values(NAVIGATION.SIDE_MENU_ITEMS) + .filter(item => { + return item.hasOwnRoute === undefined && isLocal(item.href); + }) + .map(item => ({ + params: { page: item.href.slice(1) }, + })); - return { paths, fallback: true }; -} + const cms = new CmsApi(); + const posts: IPost[] = []; + let currentPage = 1; + let foundAllPosts = false; + + // Contentful only allows 100 at a time + while (!foundAllPosts) { + const { entries: _posts } = await cms.fetchBlogEntries(100, currentPage); + + if (_posts.length === 0) { + foundAllPosts = true; + continue; + } + + posts.push(..._posts); + currentPage++; + } + + const postPaths: IPath[] = posts.map(post => ({ + params: { page: post.slug }, + })); + + return { paths: [...navigationPaths, ...postPaths], fallback: 'blocking' }; +}; export async function getStaticProps({ params }) { + console.log(`Building Page %c${params.page}`, 'color: purple;'); const href = params?.page ?? ''; const id = unslugify(String(href)); - const cms = new CmsApi(); - const page = await cms.fetchPageById(SideMenuItem[id] ?? ''); + try { + const cms = new CmsApi(); + let page: ISplitPage | IPost; - if (!page) { - return { notFound: true }; + if (SideMenuItem[id]) { + page = await cms.fetchPageById(SideMenuItem[id]); + } else { + page = await cms.fetchEntryBySlug(href, 'post'); + // embedded links in post body need metadata for preview + page.body = await generateLinkMeta(page.body); + } + + return { + props: { + page, + href: `/${href}`, + }, + revalidate: CMS.CONTENT_REVALIDATE_RATE, + }; + } catch (err) { + console.error(err); + return { + notFound: true, + revalidate: CMS.CONTENT_REVALIDATE_RATE, + }; } - - return { - props: { - page, - href: `/${href}`, - }, - revalidate: CMS.CONTENT_REVALIDATE_RATE, - }; } -function Page({ page, href }: { page: ISplitPage | null; href: string }) { - const dispatch = useDispatch(); - useEffect(() => { - dispatch(setPageType(PageType.NORMAL)); - }, []); - - const pageTitle = generateTitle(page?.label); - const pageDescription = page?.title; - const pageURL = generateURL(href); - - return ( - <> - - {pageTitle} - - - - - - - - - - - - - - -
-
- {page?.hero?.description -
- - -

- {page?.title} -

- -
- -
-
-
- - ); +export default function Page({ + page, + href, +}: { + page: ISplitPage | IPost; + href: string; +}) { + if (isPost(page)) { + return ; + } else { + return ; + } } - -export default Page; diff --git a/pages/blog/[[...page]].tsx b/pages/blog/[[...page]].tsx new file mode 100644 index 0000000..1127db9 --- /dev/null +++ b/pages/blog/[[...page]].tsx @@ -0,0 +1,148 @@ +import { useEffect, ReactElement } from 'react'; +import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { useDispatch } from 'react-redux'; + +import { CMS, METADATA } from '../../constants'; +import { CmsApi } from '../../services/cms'; +import { PageType, setPageType } from '../../state/navigation'; +import { IPath } from '../../types'; +import { IPost } from '../../types/cms'; +import { generateTitle } from '../../utils/metadata'; + +import { ArticleCard } from '../../components/cards/ArticleCard'; +import { ArticleCardFeature } from '../../components/cards/ArticleCardFeature'; +import { CardGrid } from '../../components/cards/CardGrid'; +import { Contained } from '../../components/Contained'; +import Pagination from '../../components/Pagination'; + +interface Props { + posts: IPost[]; + currentPage: number; + pageCount: number; +} + +export default function Blog(props: Props): ReactElement { + const { + posts: [featuredPost, ...otherPosts], + currentPage, + pageCount, + } = props; + const router = useRouter(); + const dispatch = useDispatch(); + const pageTitle = generateTitle('Blog'); + const featuredImageURL = featuredPost?.featureImage?.imageUrl; + + const paginationHandler = page => { + const newRoute = `/blog/${page.selected + 1}`; + router.push(newRoute); + }; + + useEffect(() => { + dispatch(setPageType(PageType.BLOG)); + }, []); + + return ( + <> + + {pageTitle} + + + + + + + + + + + + + +
+ +

+ Oxen Blogs +

+ {featuredPost && } +
+ + + {otherPosts?.map(post => ( + + ))} + + + +
+ + ); +} + +export const getStaticProps: GetStaticProps = async ( + context: GetStaticPropsContext, +) => { + console.log( + `Building Blog posts page %c${ + context.params.page ? context.params.page[0] : '' + }`, + 'color: purple;', + ); + + const cms = new CmsApi(); + const page = context.params.page ? Number(context.params.page[0]) : 1; + + try { + const { + entries: posts, + total, + } = await cms.fetchBlogEntriesWithoutDevUpdates( + CMS.BLOG_RESULTS_PER_PAGE, + page, + ); + + const pageCount = Math.ceil(total / CMS.BLOG_RESULTS_PER_PAGE); + + if (page > pageCount && page > 1) { + throw 'Page results exceeded!'; + } + + return { + props: { + posts, + pageCount, + currentPage: page, + }, + revalidate: CMS.CONTENT_REVALIDATE_RATE, + }; + } catch (err) { + console.error(err); + return { + notFound: true, + revalidate: CMS.CONTENT_REVALIDATE_RATE, + }; + } +}; + +export const getStaticPaths: GetStaticPaths = async () => { + const cms = new CmsApi(); + + const { entries, total } = await cms.fetchBlogEntriesWithoutDevUpdates(); + const pageCount = Math.ceil(total / CMS.BLOG_RESULTS_PER_PAGE); + const paths: IPath[] = []; + + for (let i = 1; i <= pageCount; i++) { + paths.push({ params: { page: [String(i)] } }); + } + + return { paths, fallback: 'blocking' }; +}; diff --git a/pages/blog/[slug].tsx b/pages/blog/[slug].tsx deleted file mode 100644 index 54da45b..0000000 --- a/pages/blog/[slug].tsx +++ /dev/null @@ -1,108 +0,0 @@ -// [slug].js -import Head from 'next/head'; -import React, { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { Article } from '../../components/article/Article'; -import { CMS } from '../../constants'; -import { CmsApi, generateLinkMeta } from '../../services/cms'; -import { PageType, setPageType, setPostTitle } from '../../state/navigation'; -import { IPost } from '../../types/cms'; -import { generateTitle, generateURL } from '../../utils/metadata'; - -interface IPath { - params: { slug: string }; -} - -export async function getStaticPaths() { - const cms = new CmsApi(); - let posts: IPost[] = []; - let page = 1; - let foundAllPosts = false; - - // Contentful only allows 100 at a time - while (!foundAllPosts) { - const { entries: _posts } = await cms.fetchBlogEntries(100, page); - - if (_posts.length === 0) { - foundAllPosts = true; - continue; - } - - posts = [...posts, ..._posts]; - page++; - } - - const paths: IPath[] = posts.map(item => ({ - params: { slug: item.slug }, - })); - - return { paths, fallback: true }; -} - -export async function getStaticProps({ params }) { - console.log(`Building page: %c${params.slug}`, 'color: purple;'); - - const cms = new CmsApi(); - const post = await cms.fetchEntryBySlug(String(params?.slug) ?? '', 'post'); - // embedded links in post body need metadata for preview - post.body = await generateLinkMeta(post.body); - const url = generateURL(params?.slug ? `/blog/${params?.slug}` : '/blog'); - if (!post) { - return { notFound: true }; - } - - return { - props: { - post, - url, - }, - revalidate: CMS.CONTENT_REVALIDATE_RATE, - }; -} - -// Parallax on bg as mouse moves -function Post({ post, url }: { post: IPost; url: string }) { - const dispatch = useDispatch(); - - useEffect(() => { - if (post) { - dispatch(setPageType(PageType.POST)); - dispatch(setPostTitle(post.title)); - } - }, []); - - const pageTitle = generateTitle(post?.title); - const imageURL = post?.featureImage?.imageUrl; - - return ( - <> - - {pageTitle} - - - - - - - - - - {' '} - - - - - - -
-
-
- - ); -} - -export default Post; diff --git a/pages/blog/index.tsx b/pages/blog/index.tsx deleted file mode 100644 index ae6372b..0000000 --- a/pages/blog/index.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { GetServerSideProps } from 'next'; -import Head from 'next/head'; -import { useRouter } from 'next/router'; -import React, { useEffect, useContext } from 'react'; -import ReactPaginate from 'react-paginate'; -import { useDispatch } from 'react-redux'; -import { ArticleCard } from '../../components/cards/ArticleCard'; -import { ArticleCardFeature } from '../../components/cards/ArticleCardFeature'; -import { CardGrid } from '../../components/cards/CardGrid'; -import { Contained } from '../../components/Contained'; -import { TagBlock } from '../../components/TagBlock'; -import { CMS, METADATA } from '../../constants'; -import { CmsApi } from '../../services/cms'; -import { PageType, setPageType } from '../../state/navigation'; -import { IPost } from '../../types/cms'; -import { generateTitle } from '../../utils/metadata'; -import { ScreenContext } from '../../contexts/screen'; - -export const getServerSideProps: GetServerSideProps = async context => { - const cms = new CmsApi(); - - // Get tag query - const tag = String(context.query.tag ?? '') ?? null; - const page = Math.ceil(Number(context.query.page ?? 1)); - - const RESULTS_PER_PAGE = tag - ? CMS.BLOG_RESULTS_PER_PAGE_TAGGED - : CMS.BLOG_RESULTS_PER_PAGE; - - // Fetch posts even when tag, for related etc - // Pagination only occurs when tag isnt defined. - // If tag is defined, pagination is for tag results - const { entries: posts, total: totalPosts } = await cms.fetchBlogEntries( - RESULTS_PER_PAGE, - tag ? 1 : page, - ); - - // Get tags for pagination - let tagPosts = []; - let tagTotalPosts; - let filteredPosts = posts; - let filteredTotalPosts = totalPosts; - if (tag) { - const { - entries: _tagPosts = [], - total: _tagTotalPosts, - } = await cms.fetchBlogEntriesByTag(tag ?? '', RESULTS_PER_PAGE, page); - tagPosts = _tagPosts; - tagTotalPosts = _tagTotalPosts; - } else { - // Retrieve all blog posts without the `dev-update` tag when not searching by tag - const { - entries: _tagPosts = [], - total: _tagTotalPosts, - } = await cms.fetchBlogEntriesWithoutDevUpdates(RESULTS_PER_PAGE, page); - - filteredPosts = _tagPosts; - filteredTotalPosts = _tagTotalPosts; - } - - const total = tagTotalPosts ?? filteredTotalPosts; - const pageCount = Math.ceil(total / RESULTS_PER_PAGE); - - return { - props: { - posts: filteredPosts, - pageCount, - currentPage: page, - tag, - tagPosts, - }, - }; -}; - -interface Props { - posts: IPost[]; - tagPosts: IPost[]; - tag: string | null; - currentPage: number; - pageCount: number; -} - -const Blog = (props: Props) => { - const { posts, tagPosts, tag, currentPage, pageCount } = props; - const { isMobile } = useContext(ScreenContext); - - const router = useRouter(); - const dispatch = useDispatch(); - - useEffect(() => { - dispatch(setPageType(PageType.BLOG)); - }, []); - - const tagHasPosts = tagPosts && tagPosts?.length > 0; - const [featuredPost, ...otherPosts] = posts; - const showPagination = posts.length > 0 && pageCount > 1; - - console.log('index ➡️ tag:', tag); - console.log('index ➡️ tagHasPosts:', tagHasPosts); - - const paginationHandler = page => { - const currentPath = router.pathname; - - // Copy current query to avoid its removing - const currentQuery = { ...router.query }; - currentQuery.page = page.selected + 1; - - router.push({ - pathname: currentPath, - query: currentQuery, - }); - }; - - // Mobile Pagination Settings - const MAX_PAGINATION_BUTTONS_MOBILE = 5; // On mobile, we want to only have a maximum of 5 pagination buttons - const hasTooManyButtons = - isMobile && pageCount > MAX_PAGINATION_BUTTONS_MOBILE; // Check if the maximum number of pagination buttons have been reached - const EDGE_PAGES = [1, 2, pageCount - 1, pageCount]; // Check if the current page is an edge page, to keep the number of pagination buttons consistent - const isEdgePage = isMobile && EDGE_PAGES.includes(currentPage); - - const pagination = ( - -
-
- '} - breakLabel={'...'} - breakClassName={'break-me'} - activeClassName={'active bg-secondary'} - containerClassName={'pagination bg-primary text-white'} - initialPage={currentPage - 1} - pageCount={pageCount} - marginPagesDisplayed={hasTooManyButtons && !isEdgePage ? 1 : 2} - pageRangeDisplayed={isMobile ? 1 : 2} - onPageChange={paginationHandler} - /> -
-
-
- ); - - const pageTitle = generateTitle('Blog'); - const featuredImageURL = featuredPost?.featureImage?.imageUrl; - - return ( -
- - {pageTitle} - - - - - - - - - - - - - -
- -

- Oxen Blogs -

- {!tag && posts.length && } - - {tag && ( - <> -
-

- {tagHasPosts - ? 'Tag Results:' - : 'There are no posts with the tag'} -

- -
- - )} -
- - {/* Tag has posts */} - {tag && tagHasPosts && ( - <> - - {tagPosts?.map(post => ( - - ))} - - - {pagination} - - )} - - - {tag && ( -

- Recent Posts -

- )} -
- - {/* Posts, or recent posts if tag */} - - {(tag ? posts : otherPosts)?.map(post => ( - - ))} - - - {!tagHasPosts && pagination} -
-
- ); -}; - -export default Blog; diff --git a/pages/tag/[...slug].tsx b/pages/tag/[...slug].tsx new file mode 100644 index 0000000..74176dc --- /dev/null +++ b/pages/tag/[...slug].tsx @@ -0,0 +1,206 @@ +import { useEffect, ReactElement } from 'react'; +import { GetStaticPaths, GetStaticProps, GetStaticPropsContext } from 'next'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { useDispatch } from 'react-redux'; +import classNames from 'classnames'; + +import { CMS, METADATA } from '../../constants'; +import { CmsApi } from '../../services/cms'; +import { PageType, setPageType } from '../../state/navigation'; +import { IPath } from '../../types'; +import { IPost } from '../../types/cms'; +import { generateTitle } from '../../utils/metadata'; + +import { ArticleCard } from '../../components/cards/ArticleCard'; +import { CardGrid } from '../../components/cards/CardGrid'; +import { Contained } from '../../components/Contained'; +import { TagBlock } from '../../components/TagBlock'; +import Pagination from '../../components/Pagination'; + +interface Props { + posts: IPost[]; + tagPosts: IPost[]; + tag: string; + currentPage: number; + pageCount: number; +} + +export default function Tag(props: Props): ReactElement { + const { posts, tagPosts, tag, currentPage, pageCount } = props; + const router = useRouter(); + const dispatch = useDispatch(); + + const tagHasPosts = tagPosts && tagPosts?.length > 0; + const pageTitle = generateTitle(`${tag} Archives`); + const featuredPost = posts[0]; + const featuredImageURL = featuredPost?.featureImage?.imageUrl; + + const paginationHandler = page => { + const newRoute = `/tag/${tag}/${page.selected + 1}`; + router.push(newRoute); + }; + + useEffect(() => { + dispatch(setPageType(PageType.BLOG)); + }, []); + + return ( + <> + + {pageTitle} + + + + + + + + + + + + + +
+ +

+ Oxen Blogs +

+
+

+ {tagHasPosts + ? 'Tag Results:' + : 'There are no posts with the tag:'} +

+ +
+
+ + {tagHasPosts && ( + <> + + {tagPosts?.map(post => ( + + ))} + + + + + )} + + +

+ Recent Posts +

+
+ + + {posts?.map(post => ( + + ))} + +
+ + ); +} + +export const getStaticProps: GetStaticProps = async ( + context: GetStaticPropsContext, +) => { + console.log( + `Building Results for tag "%c${context.params.slug[0]}" page ${ + context.params.slug && context.params.slug[1] + ? context.params.slug[1] + : '' + }`, + 'color: purple;', + ); + + const cms = new CmsApi(); + const tag = String(context.params.slug[0] ?? '') ?? null; + const page = Number(context.params.slug[1] ?? 1); + + try { + const { + entries: tagPosts = [], + total: tagTotalPosts, + } = await cms.fetchBlogEntriesByTag( + tag, + CMS.BLOG_RESULTS_PER_PAGE_TAGGED, + page, + ); + + const { entries: posts, total: totalPosts } = await cms.fetchBlogEntries( + CMS.BLOG_RESULTS_PER_PAGE_TAGGED, + ); + + const pageCount = Math.ceil( + tagTotalPosts / CMS.BLOG_RESULTS_PER_PAGE_TAGGED, + ); + + if (page > pageCount && page > 1) { + throw 'Page results exceeded!'; + } + + return { + props: { + posts, + pageCount, + currentPage: page, + tag, + tagPosts, + }, + revalidate: CMS.CONTENT_REVALIDATE_RATE, + }; + } catch (err) { + console.error(err); + return { + notFound: true, + revalidate: CMS.CONTENT_REVALIDATE_RATE, + }; + } +}; + +export const getStaticPaths: GetStaticPaths = async () => { + const cms = new CmsApi(); + + const tags = Object.values(await cms.fetchTagList()); + const paths: IPath[] = []; + + for (let i = 0; i < tags.length; i++) { + const { entries, total } = await cms.fetchBlogEntriesByTag(tags[i]); + const pageCount = Math.ceil(total / CMS.BLOG_RESULTS_PER_PAGE); + const _paths = []; + + for (let i = 1; i <= pageCount; i++) { + _paths.push({ params: { slug: [tags[i], String(i)] } }); + } + + paths.push(..._paths); + } + + return { + paths, + fallback: 'blocking', + }; +}; diff --git a/services/cms.tsx b/services/cms.tsx index b4bddad..9943348 100644 --- a/services/cms.tsx +++ b/services/cms.tsx @@ -20,6 +20,7 @@ import { IFetchBlogEntriesReturn, IFetchEntriesReturn, IFetchFAQItemsReturn, + ITagList, } from '../types/cms'; import isLive from '../utils/environment'; import { generateURL } from '../utils/metadata'; @@ -45,6 +46,20 @@ export class CmsApi { }); } + public async fetchTagList(): Promise { + // TODO Migrate to Contentful Tag System + const { entries, total } = await this.fetchBlogEntries(); + const tags: ITagList = {}; + entries.forEach(entry => { + entry.tags.forEach(tag => { + if (!tags[tag]) { + tags[tag] = tag; + } + }); + }); + return tags; + } + public async fetchBlogEntries( quantity = CMS.BLOG_RESULTS_PER_PAGE, page = 1, diff --git a/services/render.tsx b/services/render.tsx index 695aa0a..596408b 100644 --- a/services/render.tsx +++ b/services/render.tsx @@ -7,7 +7,7 @@ import sanitize from '../utils/sanitize'; import EmbedContent from '../components/EmbedContent'; import { ScreenContext } from '../contexts/screen'; -import { ReactElement } from 'react'; +import { ReactElement, CSSProperties } from 'react'; function Markup(node: any): ReactElement { const frontTags: string[] = []; @@ -72,7 +72,7 @@ function EmbeddedMedia(node: any, isInline = false): ReactElement { const imageWidth = node.width ?? media.file.details.image.width; const imageHeight = node.height ?? media.file.details.image.height; const figureClasses = [ - isInline && node.position && 'text-center mx-auto mb-5', + isInline && node.position && 'text-center mx-auto mt-4 mb-5', isInline && !node.position && 'inline-block align-middle mx-1', isInline && node.position === 'left' && 'tablet:float-left tablet:mr-4', isInline && node.position === 'right' && 'tablet:float-right tablet:ml-4', @@ -84,14 +84,15 @@ function EmbeddedMedia(node: any, isInline = false): ReactElement { !node.position && 'text-center tablet:inline-block tablet:align-middle tablet:mx-1', ]; + const figureStyles: CSSProperties = {}; + if (!isMobile && node.position) { + figureStyles.width = imageWidth; + } + if (isDesktop) { + figureStyles.maxWidth = '800px'; + } return ( -
+
{node.title}; } diff --git a/types/index.ts b/types/index.ts index 6fd6ddc..3c8a2ce 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,2 +1,6 @@ // Common types export type SVG = React.FunctionComponent>; + +export interface IPath { + params: { page: string | string[] }; +} diff --git a/yarn.lock b/yarn.lock index 19c33fc..e4f44e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1414,32 +1414,32 @@ exec-sh "^0.3.2" minimist "^1.2.0" -"@contentful/rich-text-html-renderer@^15.0.0": - version "15.2.0" - resolved "https://registry.yarnpkg.com/@contentful/rich-text-html-renderer/-/rich-text-html-renderer-15.2.0.tgz#112bb9f748b5bc4df9d2e2065cb9f6d6e9ca085a" - integrity sha512-htcPzKiKsfINv6wkHtx38Vw0QLeGwzRTLe8dNHU2lvGZ+tgmqsgmt6C0XcpBbdon98Afl8xdToOpZdfQImI8NA== +"@contentful/rich-text-html-renderer@^15.3.5": + version "15.3.5" + resolved "https://registry.yarnpkg.com/@contentful/rich-text-html-renderer/-/rich-text-html-renderer-15.3.5.tgz#b8e9d5226f9e75f2ac7cf2c5761d2b4ea195b7bd" + integrity sha512-xB9mL8Vb1Ebh0To5jxDb1mYuf1fy4j/pfcl8ZxQppB9nIc7jlHfYUePMl3P3t1kPtED7GjHmsV7kLAxEoWqtgg== dependencies: - "@contentful/rich-text-types" "^15.1.0" + "@contentful/rich-text-types" "^15.3.5" escape-html "^1.0.3" -"@contentful/rich-text-plain-text-renderer@^15.0.0": - version "15.1.0" - resolved "https://registry.yarnpkg.com/@contentful/rich-text-plain-text-renderer/-/rich-text-plain-text-renderer-15.1.0.tgz#d6deb9f78de24b606d96dffd00abe8e2f957801e" - integrity sha512-aPyDMQpb5rXfTJFRDur10qNZm1roKtXcXi6nIUP582NSLEzkp5GRydJsbp0Zcu+8qLcNMfvc7nwn4725P+Ma8w== +"@contentful/rich-text-plain-text-renderer@^15.3.5": + version "15.3.5" + resolved "https://registry.yarnpkg.com/@contentful/rich-text-plain-text-renderer/-/rich-text-plain-text-renderer-15.3.5.tgz#d92761ca118c5f0b16b2c55c9a190a8c02e75ffd" + integrity sha512-AFNP1gAopmFiFK8E4qzMcgemYzSUHfmgVZeZAzypPDKQhhS3x/L4u9kihZcK55YkLpSJgUisc8XcrJlUTYMl4g== dependencies: - "@contentful/rich-text-types" "^15.1.0" + "@contentful/rich-text-types" "^15.3.5" -"@contentful/rich-text-react-renderer@^15.0.0": - version "15.2.0" - resolved "https://registry.yarnpkg.com/@contentful/rich-text-react-renderer/-/rich-text-react-renderer-15.2.0.tgz#b5ff25b4746f063f15d83eccaff555597d46683e" - integrity sha512-mMvs1tvd8Uab1Rqu1oNNqrVwaiQ3+EiLbjZQkBuQ0vTpVCmFI5w60X/mUg5aLMQzJMyEW/kzzcGEjo5oG7im5Q== +"@contentful/rich-text-react-renderer@^15.3.5": + version "15.3.5" + resolved "https://registry.yarnpkg.com/@contentful/rich-text-react-renderer/-/rich-text-react-renderer-15.3.5.tgz#94a94ea937c7a2f331942307f9998c460a5dd221" + integrity sha512-BDZQ0Z1koj5C9VzOt9V5vaEIKRfbakHBqU2DpjmWCx0h+wRdmmVQdTX6VHousH7TOHjWLUi8w7xHH3Kjnzh0EQ== dependencies: - "@contentful/rich-text-types" "^15.1.0" + "@contentful/rich-text-types" "^15.3.5" -"@contentful/rich-text-types@^15.0.0", "@contentful/rich-text-types@^15.1.0": - version "15.1.0" - resolved "https://registry.yarnpkg.com/@contentful/rich-text-types/-/rich-text-types-15.1.0.tgz#5637452257e6c7c6fc406ec1ec6ab7fcc1158105" - integrity sha512-3PgQi/B5yKC3xXDar1Ry4i4l9jgt56PFZ40cOkE+oCNLmNlFtZolo6cTrf4VunuufSsCfjTs4iWovy7fbM1I8Q== +"@contentful/rich-text-types@^15.3.5": + version "15.3.5" + resolved "https://registry.yarnpkg.com/@contentful/rich-text-types/-/rich-text-types-15.3.5.tgz#414e7b4e671d9c3a4f75e5c762e4e109ccd4ba20" + integrity sha512-NOAdIfWUadOxhnv4IR+vBtgPtAtV0hv1uoZJogm2mniXZHRIpmKNbEBncjfr/SSHhOG98tQgHCUw6b/nvDTVaQ== "@csstools/convert-colors@^1.4.0": version "1.4.0"