mirror of
https://github.com/oxen-io/oxen-website.git
synced 2023-12-13 21:00:18 +01:00
Merge pull request #33 from yougotwill/dynamic-performance
Blog routes now use ISR
This commit is contained in:
commit
d135a4ef07
53
components/BlogPost.tsx
Normal file
53
components/BlogPost.tsx
Normal 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
45
components/Pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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
76
components/RichPage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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\]))$/,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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",
|
||||||
|
|
177
pages/[page].tsx
177
pages/[page].tsx
|
@ -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
148
pages/blog/[[...page]].tsx
Normal 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' };
|
||||||
|
};
|
|
@ -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;
|
|
|
@ -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
206
pages/tag/[...slug].tsx
Normal 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',
|
||||||
|
};
|
||||||
|
};
|
|
@ -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,
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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[] };
|
||||||
|
}
|
||||||
|
|
38
yarn.lock
38
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue