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 children;
const plaintext = documentToPlainTextString(node); const plaintext = documentToPlainTextString(node);
const isShortcode = CMS.SHORTCODE_REGEX.test(plaintext); const isShortcode = CMS.SHORTCODE_REGEX.test(plaintext);
if (isShortcode) {
return isShortcode ? ( return renderShortcode(plaintext);
renderShortcode(plaintext) } else {
) : ( let hasImage = false;
<p Children.map(children, (child: any) => {
className={classNames('mb-3 font-sans tracking-wide text-justify')} if (child.type === 'figure') {
> hasImage = true;
{children} return;
</p> }
); });
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) => ( [BLOCKS.HEADING_1]: (node, children) => (
<h1 <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 { interface Props {
tag: string; tag: string;
size?: 'small' | 'medium' | 'large'; size?: 'small' | 'medium' | 'large';
classes?: string;
} }
export function TagBlock(props: Props) { export function TagBlock(props: Props) {
const { tag, size = 'small' } = props; const { tag, size = 'small', classes } = props;
const href = `/blog?tag=${tag.toLowerCase()}`; const href = `/tag/${tag.toLowerCase()}`;
return ( return (
<a <a
@ -19,6 +20,7 @@ export function TagBlock(props: Props) {
size === 'small' && 'h-4 text-xs', size === 'small' && 'h-4 text-xs',
size === 'medium' && 'h-5 text-sm', size === 'medium' && 'h-5 text-sm',
size === 'medium' && 'h-6 text-base', size === 'medium' && 'h-6 text-base',
classes,
)} )}
> >
<span className="px-2">{tag.toLowerCase()}</span> <span className="px-2">{tag.toLowerCase()}</span>

View file

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

View file

@ -61,11 +61,13 @@ const SIDE_MENU_ITEMS = {
id: 8, id: 8,
label: "Oxen's 2021 roadmap", label: "Oxen's 2021 roadmap",
href: '/roadmap', href: '/roadmap',
hasOwnRoute: true,
}, },
[SideMenuItem.FAQ]: { [SideMenuItem.FAQ]: {
id: 9, id: 9,
label: 'FAQ', label: 'FAQ',
href: '/faq', href: '/faq',
hasOwnRoute: true,
}, },
} as { [name: string]: ISideMenuItem }; } as { [name: string]: ISideMenuItem };
@ -79,7 +81,7 @@ const MENU_ITEMS: IMenuItem[] = [
}, },
{ {
label: 'Dev Updates', label: 'Dev Updates',
href: '/blog?tag=dev-update', href: '/tag/dev-update',
newTab: false, newTab: false,
subtle: true, subtle: true,
external: false, external: false,
@ -125,7 +127,7 @@ const MENU_ITEMS: IMenuItem[] = [
const NAVIGATION = { const NAVIGATION = {
MENU_ITEMS, MENU_ITEMS,
SIDE_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\]))$/, POST_REGEX: /^\/(blog\/)(([\w-]{1,100})|(\[slug\]))$/,
}; };

View file

@ -41,10 +41,15 @@ const nextConfig = {
destination: '/api/feed/rss', destination: '/api/feed/rss',
}, },
{ {
// The /:slug part is a generic parameter handler to catch all other cases
source: '/feed/:slug', source: '/feed/:slug',
destination: '/api/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": { "dependencies": {
"@ant-design/icons": "^4.2.2", "@ant-design/icons": "^4.2.2",
"@contentful/rich-text-html-renderer": "^15.0.0", "@contentful/rich-text-html-renderer": "^15.3.5",
"@contentful/rich-text-plain-text-renderer": "^15.0.0", "@contentful/rich-text-plain-text-renderer": "^15.3.5",
"@contentful/rich-text-react-renderer": "^15.0.0", "@contentful/rich-text-react-renderer": "^15.3.5",
"@tailwindcss/aspect-ratio": "^0.2.0", "@tailwindcss/aspect-ratio": "^0.2.0",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"@types/lodash": "^4.14.161", "@types/lodash": "^4.14.161",
@ -64,7 +64,7 @@
"@babel/core": "^7.12.17", "@babel/core": "^7.12.17",
"@babel/preset-env": "^7.12.17", "@babel/preset-env": "^7.12.17",
"@babel/preset-react": "^7.12.13", "@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", "@tailwindcss/forms": "^0.3.3",
"@types/base-64": "^1.0.0", "@types/base-64": "^1.0.0",
"@types/lodash.get": "^4.4.6", "@types/lodash.get": "^4.4.6",

View file

@ -1,111 +1,94 @@
// [slug].js import { GetStaticPaths } from 'next';
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 { CMS, NAVIGATION } from '../constants'; import { CMS, NAVIGATION } from '../constants';
import { CmsApi, unslugify } from '../services/cms'; import { CmsApi, generateLinkMeta, unslugify } from '../services/cms';
import { PageType, setPageType, SideMenuItem } from '../state/navigation'; import { SideMenuItem } from '../state/navigation';
import { ISplitPage } from '../types/cms'; import { IPath } from '../types';
import { generateTitle, generateURL } from '../utils/metadata'; import { IPost, ISplitPage, isPost } from '../types/cms';
import { isLocal } from '../utils/links';
interface IPath { import BlogPost from '../components/BlogPost';
params: { page: string }; import RichPage from '../components/RichPage';
}
export async function getStaticPaths() { export const getStaticPaths: GetStaticPaths = async () => {
// Get paths to all pages // Get paths to all pages stored in navigation constants.
// Hardcoded in navigation constants. // Contentful can edit entries but cannot add/remove without touching code.
// Contentful can edit entries but cannot add/remove const navigationPaths: IPath[] = Object.values(NAVIGATION.SIDE_MENU_ITEMS)
// without touching code. .filter(item => {
const paths: IPath[] = Object.values(NAVIGATION.SIDE_MENU_ITEMS).map( return item.hasOwnRoute === undefined && isLocal(item.href);
item => ({ })
params: { page: 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 }) { export async function getStaticProps({ params }) {
console.log(`Building Page %c${params.page}`, 'color: purple;');
const href = params?.page ?? ''; const href = params?.page ?? '';
const id = unslugify(String(href)); const id = unslugify(String(href));
const cms = new CmsApi(); try {
const page = await cms.fetchPageById(SideMenuItem[id] ?? ''); const cms = new CmsApi();
let page: ISplitPage | IPost;
if (!page) { if (SideMenuItem[id]) {
return { notFound: true }; 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 }) { export default function Page({
const dispatch = useDispatch(); page,
useEffect(() => { href,
dispatch(setPageType(PageType.NORMAL)); }: {
}, []); page: ISplitPage | IPost;
href: string;
const pageTitle = generateTitle(page?.label); }) {
const pageDescription = page?.title; if (isPost(page)) {
const pageURL = generateURL(href); return <BlogPost post={page} url={href} />;
} else {
return ( return <RichPage page={page} href={href} />;
<> }
<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 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, IFetchBlogEntriesReturn,
IFetchEntriesReturn, IFetchEntriesReturn,
IFetchFAQItemsReturn, IFetchFAQItemsReturn,
ITagList,
} from '../types/cms'; } from '../types/cms';
import isLive from '../utils/environment'; import isLive from '../utils/environment';
import { generateURL } from '../utils/metadata'; 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( public async fetchBlogEntries(
quantity = CMS.BLOG_RESULTS_PER_PAGE, quantity = CMS.BLOG_RESULTS_PER_PAGE,
page = 1, page = 1,

View file

@ -7,7 +7,7 @@ import sanitize from '../utils/sanitize';
import EmbedContent from '../components/EmbedContent'; import EmbedContent from '../components/EmbedContent';
import { ScreenContext } from '../contexts/screen'; import { ScreenContext } from '../contexts/screen';
import { ReactElement } from 'react'; import { ReactElement, CSSProperties } from 'react';
function Markup(node: any): ReactElement { function Markup(node: any): ReactElement {
const frontTags: string[] = []; const frontTags: string[] = [];
@ -72,7 +72,7 @@ function EmbeddedMedia(node: any, isInline = false): ReactElement {
const imageWidth = node.width ?? media.file.details.image.width; const imageWidth = node.width ?? media.file.details.image.width;
const imageHeight = node.height ?? media.file.details.image.height; const imageHeight = node.height ?? media.file.details.image.height;
const figureClasses = [ 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 && 'inline-block align-middle mx-1',
isInline && node.position === 'left' && 'tablet:float-left tablet:mr-4', isInline && node.position === 'left' && 'tablet:float-left tablet:mr-4',
isInline && node.position === 'right' && 'tablet:float-right tablet:ml-4', isInline && node.position === 'right' && 'tablet:float-right tablet:ml-4',
@ -84,14 +84,15 @@ function EmbeddedMedia(node: any, isInline = false): ReactElement {
!node.position && !node.position &&
'text-center tablet:inline-block tablet:align-middle tablet:mx-1', '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 ( return (
<figure <figure className={classNames(figureClasses)} style={figureStyles}>
className={classNames(figureClasses)}
style={{
width: !isMobile && node.position ? imageWidth : '',
maxWidth: isDesktop ? '800px' : '',
}}
>
<Image <Image
src={`${url}${isMobile ? '?w=300' : isTablet ? '?w=600' : ''}`} src={`${url}${isMobile ? '?w=300' : isTablet ? '?w=600' : ''}`}
alt={node.title} alt={node.title}

View file

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

View file

@ -1,2 +1,6 @@
// Common types // Common types
export type SVG = React.FunctionComponent<React.SVGProps<SVGSVGElement>>; 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" exec-sh "^0.3.2"
minimist "^1.2.0" minimist "^1.2.0"
"@contentful/rich-text-html-renderer@^15.0.0": "@contentful/rich-text-html-renderer@^15.3.5":
version "15.2.0" version "15.3.5"
resolved "https://registry.yarnpkg.com/@contentful/rich-text-html-renderer/-/rich-text-html-renderer-15.2.0.tgz#112bb9f748b5bc4df9d2e2065cb9f6d6e9ca085a" resolved "https://registry.yarnpkg.com/@contentful/rich-text-html-renderer/-/rich-text-html-renderer-15.3.5.tgz#b8e9d5226f9e75f2ac7cf2c5761d2b4ea195b7bd"
integrity sha512-htcPzKiKsfINv6wkHtx38Vw0QLeGwzRTLe8dNHU2lvGZ+tgmqsgmt6C0XcpBbdon98Afl8xdToOpZdfQImI8NA== integrity sha512-xB9mL8Vb1Ebh0To5jxDb1mYuf1fy4j/pfcl8ZxQppB9nIc7jlHfYUePMl3P3t1kPtED7GjHmsV7kLAxEoWqtgg==
dependencies: dependencies:
"@contentful/rich-text-types" "^15.1.0" "@contentful/rich-text-types" "^15.3.5"
escape-html "^1.0.3" escape-html "^1.0.3"
"@contentful/rich-text-plain-text-renderer@^15.0.0": "@contentful/rich-text-plain-text-renderer@^15.3.5":
version "15.1.0" version "15.3.5"
resolved "https://registry.yarnpkg.com/@contentful/rich-text-plain-text-renderer/-/rich-text-plain-text-renderer-15.1.0.tgz#d6deb9f78de24b606d96dffd00abe8e2f957801e" resolved "https://registry.yarnpkg.com/@contentful/rich-text-plain-text-renderer/-/rich-text-plain-text-renderer-15.3.5.tgz#d92761ca118c5f0b16b2c55c9a190a8c02e75ffd"
integrity sha512-aPyDMQpb5rXfTJFRDur10qNZm1roKtXcXi6nIUP582NSLEzkp5GRydJsbp0Zcu+8qLcNMfvc7nwn4725P+Ma8w== integrity sha512-AFNP1gAopmFiFK8E4qzMcgemYzSUHfmgVZeZAzypPDKQhhS3x/L4u9kihZcK55YkLpSJgUisc8XcrJlUTYMl4g==
dependencies: dependencies:
"@contentful/rich-text-types" "^15.1.0" "@contentful/rich-text-types" "^15.3.5"
"@contentful/rich-text-react-renderer@^15.0.0": "@contentful/rich-text-react-renderer@^15.3.5":
version "15.2.0" version "15.3.5"
resolved "https://registry.yarnpkg.com/@contentful/rich-text-react-renderer/-/rich-text-react-renderer-15.2.0.tgz#b5ff25b4746f063f15d83eccaff555597d46683e" resolved "https://registry.yarnpkg.com/@contentful/rich-text-react-renderer/-/rich-text-react-renderer-15.3.5.tgz#94a94ea937c7a2f331942307f9998c460a5dd221"
integrity sha512-mMvs1tvd8Uab1Rqu1oNNqrVwaiQ3+EiLbjZQkBuQ0vTpVCmFI5w60X/mUg5aLMQzJMyEW/kzzcGEjo5oG7im5Q== integrity sha512-BDZQ0Z1koj5C9VzOt9V5vaEIKRfbakHBqU2DpjmWCx0h+wRdmmVQdTX6VHousH7TOHjWLUi8w7xHH3Kjnzh0EQ==
dependencies: 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": "@contentful/rich-text-types@^15.3.5":
version "15.1.0" version "15.3.5"
resolved "https://registry.yarnpkg.com/@contentful/rich-text-types/-/rich-text-types-15.1.0.tgz#5637452257e6c7c6fc406ec1ec6ab7fcc1158105" resolved "https://registry.yarnpkg.com/@contentful/rich-text-types/-/rich-text-types-15.3.5.tgz#414e7b4e671d9c3a4f75e5c762e4e109ccd4ba20"
integrity sha512-3PgQi/B5yKC3xXDar1Ry4i4l9jgt56PFZ40cOkE+oCNLmNlFtZolo6cTrf4VunuufSsCfjTs4iWovy7fbM1I8Q== integrity sha512-NOAdIfWUadOxhnv4IR+vBtgPtAtV0hv1uoZJogm2mniXZHRIpmKNbEBncjfr/SSHhOG98tQgHCUw6b/nvDTVaQ==
"@csstools/convert-colors@^1.4.0": "@csstools/convert-colors@^1.4.0":
version "1.4.0" version "1.4.0"