Merge pull request #33 from yougotwill/dynamic-performance

Blog routes now use ISR
This commit is contained in:
William Grant 2021-09-17 14:51:31 +10:00 committed by GitHub
commit d135a4ef07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 710 additions and 474 deletions

53
components/BlogPost.tsx Normal file
View File

@ -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 (
<>
<Head>
<title>{pageTitle}</title>
<meta name="description" content={post?.description}></meta>
<meta property="og:title" content={pageTitle} key="ogtitle" />
<meta
property="og:description"
content={post?.description}
key="ogdesc"
/>
<meta property="og:type" content="article" />
<meta name="image_src" content={imageURL} />
<meta name="image_url" content={imageURL} />
<meta name="keywords" content={post?.tags?.join(' ')} />
<meta property="og:image" content={imageURL} key="ogimage" />
<meta property="og:url" content={url} />
<link rel="canonical" href={url}></link>{' '}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={post?.description} />
<meta name="twitter:image" content={imageURL} />
</Head>
<div className="bg-alt">
<Article {...post} />
</div>
</>
);
}

45
components/Pagination.tsx Normal file
View File

@ -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 (
<Contained>
<div className="flex justify-center mb-4">
<div className="mt-6 tablet:mt-4">
<ReactPaginate
previousLabel={'<'}
nextLabel={'>'}
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}
/>
</div>
</div>
</Contained>
);
}

View File

@ -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)
) : (
<p
className={classNames('mb-3 font-sans tracking-wide text-justify')}
>
{children}
</p>
);
if (isShortcode) {
return renderShortcode(plaintext);
} else {
let hasImage = false;
Children.map(children, (child: any) => {
if (child.type === 'figure') {
hasImage = true;
return;
}
});
if (hasImage) {
return (
<span className={classNames('leading-relaxed pb-6')}>
{children}
</span>
);
}
return (
<p
className={classNames(
'mb-3 font-sans tracking-wide text-justify',
)}
>
{children}
</p>
);
}
},
[BLOCKS.HEADING_1]: (node, children) => (
<h1

76
components/RichPage.tsx Normal file
View File

@ -0,0 +1,76 @@
import React, { useEffect } from 'react';
import Head from 'next/head';
import { useDispatch } from 'react-redux';
import { PageType, setPageType } from '../state/navigation';
import { ISplitPage } from '../types/cms';
import { generateTitle, generateURL } from '../utils/metadata';
import { Contained } from '../components/Contained';
import { RichBody } from '../components/RichBody';
export default function RichPage({
page,
href,
}: {
page: ISplitPage;
href: string;
}) {
const dispatch = useDispatch();
const pageTitle = generateTitle(page?.label);
const pageDescription = page?.title;
const pageURL = generateURL(href);
useEffect(() => {
dispatch(setPageType(PageType.NORMAL));
}, []);
return (
<>
<Head>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription}></meta>
<meta property="og:title" content={pageTitle} key="ogtitle" />
<meta
property="og:description"
content={pageDescription}
key="ogdesc"
/>
<meta property="og:type" content="website" />
<meta
property="og:image"
content={page?.hero?.imageUrl}
key="ogimage"
/>
<meta property="og:url" content={pageURL} />
<link rel="canonical" href={pageURL}></link>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<meta name="twitter:image" content={page?.hero?.imageUrl} />
</Head>
<div className="bg-alt">
<div className="relative flex items-center justify-center w-full h-full pt-3 bg-gradient-to-bl from-hyper to-blush">
<img
style={{ maxHeight: '33vh' }}
src={page?.hero?.imageUrl}
className="object-contain w-full"
alt={page?.hero?.description ?? pageTitle}
/>
</div>
<Contained>
<h1 className="mt-12 mb-4 text-4xl font-bold leading-none text-primary font-prompt">
{page?.title}
</h1>
<div className="mb-10">
<RichBody body={page?.body} />
</div>
</Contained>
</div>
</>
);
}

View File

@ -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 (
<a
@ -19,6 +20,7 @@ export function TagBlock(props: Props) {
size === 'small' && 'h-4 text-xs',
size === 'medium' && 'h-5 text-sm',
size === 'medium' && 'h-6 text-base',
classes,
)}
>
<span className="px-2">{tag.toLowerCase()}</span>

View File

@ -11,6 +11,7 @@ export interface ISideMenuItem {
label: string;
href: string;
isExternal?: boolean;
hasOwnRoute?: boolean;
shouldHide?: boolean;
}

View File

@ -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\]))$/,
};

View File

@ -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',
},
];
},
};

View File

@ -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",

View File

@ -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 (
<>
<Head>
<title>{pageTitle}</title>
<meta name="description" content={pageDescription}></meta>
<meta property="og:title" content={pageTitle} key="ogtitle" />
<meta
property="og:description"
content={pageDescription}
key="ogdesc"
/>
<meta property="og:type" content="website" />
<meta
property="og:image"
content={page?.hero?.imageUrl}
key="ogimage"
/>
<meta property="og:url" content={pageURL} />
<link rel="canonical" href={pageURL}></link>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={pageDescription} />
<meta name="twitter:image" content={page?.hero?.imageUrl} />
</Head>
<div className="bg-alt">
<div className="relative flex items-center justify-center w-full h-full pt-3 bg-gradient-to-bl from-hyper to-blush">
<img
style={{ maxHeight: '33vh' }}
src={page?.hero?.imageUrl}
className="object-contain w-full"
alt={page?.hero?.description ?? pageTitle}
/>
</div>
<Contained>
<h1 className="mt-12 mb-4 text-4xl font-bold leading-none text-primary font-prompt">
{page?.title}
</h1>
<div className="mb-10">
<RichBody body={page?.body} />
</div>
</Contained>
</div>
</>
);
export default function Page({
page,
href,
}: {
page: ISplitPage | IPost;
href: string;
}) {
if (isPost(page)) {
return <BlogPost post={page} url={href} />;
} else {
return <RichPage page={page} href={href} />;
}
}
export default Page;

148
pages/blog/[[...page]].tsx Normal file
View File

@ -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 (
<>
<Head>
<title>{pageTitle}</title>
<meta name="description" content={METADATA.BLOG.DESCRIPTION}></meta>
<meta property="og:title" content={pageTitle} key="ogtitle" />
<meta
property="og:description"
content={METADATA.BLOG.DESCRIPTION}
key="ogdesc"
/>
<meta property="og:type" content="website" />
<meta property="og:image" content={featuredImageURL} key="ogimage" />
<meta property="og:url" content={METADATA.BLOG.URL} />
<link rel="canonical" href={METADATA.BLOG.URL}></link>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={METADATA.BLOG.DESCRIPTION} />
<meta name="twitter:image" content={featuredImageURL} />
</Head>
<div className="flex flex-col w-full mt-12 mb-6 space-y-6 bg-alt">
<Contained classes={'mb-6'}>
<h1 className="mb-2 text-4xl font-medium uppercase font-prompt">
Oxen Blogs
</h1>
{featuredPost && <ArticleCardFeature {...featuredPost} />}
</Contained>
<CardGrid>
{otherPosts?.map(post => (
<ArticleCard key={post.id} {...post} />
))}
</CardGrid>
<Pagination
currentPage={currentPage}
pageCount={pageCount}
paginationHandler={paginationHandler}
/>
</div>
</>
);
}
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' };
};

View File

@ -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 (
<>
<Head>
<title>{pageTitle}</title>
<meta name="description" content={post?.description}></meta>
<meta property="og:title" content={pageTitle} key="ogtitle" />
<meta
property="og:description"
content={post?.description}
key="ogdesc"
/>
<meta property="og:type" content="article" />
<meta name="image_src" content={imageURL} />
<meta name="image_url" content={imageURL} />
<meta name="keywords" content={post?.tags?.join(' ')} />
<meta property="og:image" content={imageURL} key="ogimage" />
<meta property="og:url" content={url} />
<link rel="canonical" href={url}></link>{' '}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={post?.description} />
<meta name="twitter:image" content={imageURL} />
</Head>
<div className="bg-alt">
<Article {...post} />
</div>
</>
);
}
export default Post;

View File

@ -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 = (
<Contained>
<div className="flex justify-center mb-4">
<div className="mt-6 tablet:mt-4">
<ReactPaginate
previousLabel={'<'}
nextLabel={'>'}
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}
/>
</div>
</div>
</Contained>
);
const pageTitle = generateTitle('Blog');
const featuredImageURL = featuredPost?.featureImage?.imageUrl;
return (
<div>
<Head>
<title>{pageTitle}</title>
<meta name="description" content={METADATA.BLOG.DESCRIPTION}></meta>
<meta property="og:title" content={pageTitle} key="ogtitle" />
<meta
property="og:description"
content={METADATA.BLOG.DESCRIPTION}
key="ogdesc"
/>
<meta property="og:type" content="website" />
<meta property="og:image" content={featuredImageURL} key="ogimage" />
<meta property="og:url" content={METADATA.BLOG.URL} />
<link rel="canonical" href={METADATA.BLOG.URL}></link>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={METADATA.BLOG.DESCRIPTION} />
<meta name="twitter:image" content={featuredImageURL} />
</Head>
<div className="flex flex-col w-full mt-12 mb-6 space-y-6 bg-alt">
<Contained>
<h1 className="mb-2 text-4xl font-medium uppercase font-prompt">
Oxen Blogs
</h1>
{!tag && posts.length && <ArticleCardFeature {...featuredPost} />}
{tag && (
<>
<div className="flex items-center w-full mt-4 space-x-2 font-sans">
<p className={tagHasPosts ? 'mb-0' : 'mb-10'}>
{tagHasPosts
? 'Tag Results:'
: 'There are no posts with the tag'}
</p>
<TagBlock size="large" tag={tag} />
</div>
</>
)}
</Contained>
{/* Tag has posts */}
{tag && tagHasPosts && (
<>
<CardGrid>
{tagPosts?.map(post => (
<ArticleCard key={post.id} {...post} />
))}
</CardGrid>
{pagination}
</>
)}
<Contained>
{tag && (
<h3 className="-mb-2 text-3xl font-prompt text-primary">
Recent Posts
</h3>
)}
</Contained>
{/* Posts, or recent posts if tag */}
<CardGrid>
{(tag ? posts : otherPosts)?.map(post => (
<ArticleCard key={post.id} {...post} />
))}
</CardGrid>
{!tagHasPosts && pagination}
</div>
</div>
);
};
export default Blog;

206
pages/tag/[...slug].tsx Normal file
View File

@ -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 (
<>
<Head>
<title>{pageTitle}</title>
<meta name="description" content={METADATA.BLOG.DESCRIPTION}></meta>
<meta property="og:title" content={pageTitle} key="ogtitle" />
<meta
property="og:description"
content={METADATA.BLOG.DESCRIPTION}
key="ogdesc"
/>
<meta property="og:type" content="website" />
<meta property="og:image" content={featuredImageURL} key="ogimage" />
<meta property="og:url" content={METADATA.BLOG.URL} />
<link rel="canonical" href={METADATA.BLOG.URL}></link>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={METADATA.BLOG.DESCRIPTION} />
<meta name="twitter:image" content={featuredImageURL} />
</Head>
<div className="flex flex-col w-full mt-12 mb-6 space-y-6 bg-alt">
<Contained>
<h1 className="mb-2 text-4xl font-medium uppercase font-prompt">
Oxen Blogs
</h1>
<div
className={classNames(
'flex w-full mt-4 space-x-2 font-sans',
tagHasPosts
? 'items-center'
: 'flex-col tablet:flex-row tablet:items-center',
)}
>
<p className={'mb-0'}>
{tagHasPosts
? 'Tag Results:'
: 'There are no posts with the tag:'}
</p>
<TagBlock
size="large"
tag={tag}
classes={classNames(!tagHasPosts && 'mt-3 tablet:mt-0')}
/>
</div>
</Contained>
{tagHasPosts && (
<>
<CardGrid>
{tagPosts?.map(post => (
<ArticleCard key={post.id} {...post} />
))}
</CardGrid>
<Pagination
currentPage={currentPage}
pageCount={pageCount}
paginationHandler={paginationHandler}
/>
</>
)}
<Contained>
<h3 className="-mb-2 text-3xl font-prompt text-primary">
Recent Posts
</h3>
</Contained>
<CardGrid>
{posts?.map(post => (
<ArticleCard key={post.id} {...post} />
))}
</CardGrid>
</div>
</>
);
}
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',
};
};

View File

@ -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<ITagList> {
// 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,

View File

@ -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 (
<figure
className={classNames(figureClasses)}
style={{
width: !isMobile && node.position ? imageWidth : '',
maxWidth: isDesktop ? '800px' : '',
}}
>
<figure className={classNames(figureClasses)} style={figureStyles}>
<Image
src={`${url}${isMobile ? '?w=300' : isTablet ? '?w=600' : ''}`}
alt={node.title}

View File

@ -34,6 +34,10 @@ export interface IPost {
slug: string;
}
export function isPost(object: unknown): object is IPost {
return Object.prototype.hasOwnProperty.call(object, 'publishedDate');
}
export type BodyDocument = {
nodeType: 'document';
content: any;
@ -58,6 +62,10 @@ export interface IFetchEntriesReturn {
total: number;
}
export type ITagList = {
[key: string]: string;
};
export interface IFetchBlogEntriesReturn extends IFetchEntriesReturn {
entries: Array<IPost>;
}

View File

@ -1,2 +1,6 @@
// Common types
export type SVG = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
export interface IPath {
params: { page: string | string[] };
}

View File

@ -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"