Skip to content

Commit

Permalink
Implement blog post grid and card components, GraphQL nodes and queries
Browse files Browse the repository at this point in the history
The last remaining main page is `/blog` which presents an overview of
Nord's blog posts in a three-column grid of card components sorted
descending by their publish date. Each card consists of an cover image
together with the title of the post. The latest post spans over two
columns at the top for a nice visual structure and better
recognizability.
While a one-column card uses a cover image the latest post will use a
"banner" that will either be the same image with a larger width,
a variant of it or a completly different one.

A blog post itself makes use of the MDX features and the custom MDX
components mentioned in the paragraph above. To simplify the usage of
the "cover" and "banner" image they are be processed with Gatsby's
`onCreateNode` (1) API in combination with Gatsby's `mapping`
configuration feature (2). This allows to map the paths to the images
to a `File` node that will then be handled by the Gatsby image
processing plugin workflow (3) also documented in the commit about
image handling.

Another required node is the `heroImage` field that queries for a
`hero.(png|jpg|jpeg)` image that is used as the hero of a blog post.
To allow to also use videos in blog posts or even as header a custom
`Video` MDX component has been implemented including the optional
`heroVideo` and `heroVideoPoster` GraphQL node fields.

All together that results in the following required and optional
images/videos mapped to specific node fields with reserved file names
per blog post directory for simple usage via GraphQL queries:

- `bannerImage` <-> `banner.(png|jpg|jpeg)` - The required banner image
  of a blog post card (used when currently the latest two-column wide
  post placed on top of the grid).
- `coverImage` <-> `cover.(png|jpg|jpeg)` - The required cover image of
  a one-column blog post.
- `heroImage` <-> `hero.(png|jpg|jpeg)` - The required hero image of a
  blog post.
- `heroVideo` <-> `hero.(mp4|webm)` - The optional hero video of a blog
  post.
- `heroVideoPoster` <-> `heroposter.(png|jpg|jpeg)` - The optional
  poster image of a blog post `heroVideo`.

>>> Known Problems

To prevent the `unknwon field` GraphQL error during build time (4)
(e.g. when there are no blog posts yet) a "dummy"/"placeholder" blog
post and docs page will be created.
Anyway, this will be removed as soon as there is finally the first blog
post and docs page. Later on the project will migrate to the shiny new
schema customization API (5).

References:
  (1) https://www.gatsbyjs.org/docs/node-apis/#onCreateNode
  (2) https://www.gatsbyjs.org/docs/gatsby-config/#mapping-node-types
  (3) https://www.gatsbyjs.org/docs/working-with-images
  (4) gatsbyjs/gatsby#3344
  (5) https://www.gatsbyjs.org/blog/2019-03-04-new-schema-customization

GH-129
  • Loading branch information
arcticicestudio committed Mar 9, 2019
1 parent f2e213a commit 6d48c68
Show file tree
Hide file tree
Showing 30 changed files with 633 additions and 86 deletions.
3 changes: 2 additions & 1 deletion .gatsby/createPages.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const createPages = async ({ graphql, actions }) => {
mdxQueryResult.data.allMdx.edges.forEach(({ node }) => {
const { id } = node;
const { contentSourceType, date, relativeDirectory, slug, slugParentRoute } = node.fields;
const { draft } = node.frontmatter;
const { draft, publishTime } = node.frontmatter;

/* Only create non-draft pages in production mode while also create draft pages during development. */
if (draft && isProductionMode) return;
Expand Down Expand Up @@ -97,6 +97,7 @@ const createPages = async ({ graphql, actions }) => {
contentSourceType,
date,
id,
publishTime,
relativeDirectory,
slug,
slugParentRoute
Expand Down
57 changes: 55 additions & 2 deletions .gatsby/onCreateNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@
* License: MIT
*/

const glob = require("glob");
const { createFilePath } = require("gatsby-source-filesystem");
const { dirname } = require("path");
const { existsSync } = require("fs");

const { nodeFields, sourceInstanceTypes } = require("../src/config/internal/nodes");
const {
nodeFields,
optionalBlogPostImages,
optionalBlogPostVideos,
sourceInstanceTypes,
requiredBlogPostImages
} = require("../src/config/internal/nodes");
const { BASE_DIR_CONTENT, NODE_TYPE_MDX, REGEX_BLOG_POST_DATE } = require("../src/config/internal/constants");
const { ROUTE_BLOG, ROUTE_DOCS } = require("../src/config/routes/mappings");

Expand Down Expand Up @@ -49,7 +58,7 @@ const onCreateNode = ({ node, getNode, actions }) => {
basePath: `${BASE_DIR_CONTENT}`,
trailingSlash: false
});
const { relativeDirectory, relativePath, sourceInstanceName } = getNode(node.parent);
const { absolutePath, relativeDirectory, relativePath, sourceInstanceName } = getNode(node.parent);

if (sourceInstanceName === sourceInstanceTypes.blog.id) {
const date = extractBlogPostDateFromPath(relativePath);
Expand All @@ -60,6 +69,50 @@ const onCreateNode = ({ node, getNode, actions }) => {
);
}

/* Check for required blog post images and generate node fields. */
Object.keys(requiredBlogPostImages).forEach(image => {
const { name, nodeFieldName } = requiredBlogPostImages[image];
const matches = glob.sync(`${dirname(absolutePath)}/${name}.?(png|jpg|jpeg)`);

if (!matches.length) {
throw Error(`Required '${name}.(png|jpg|jpeg)' image not found for blog post '${relativeDirectory}'!`);
}
if (existsSync(matches[0])) {
createNodeField({
node,
name: nodeFieldName,
value: matches[0]
});
}
});

/* Check for optional blog post images and generate node fields. */
Object.keys(optionalBlogPostImages).forEach(image => {
const { name, nodeFieldName } = optionalBlogPostImages[image];
const matches = glob.sync(`${dirname(absolutePath)}/${name}.?(png|jpg|jpeg)`);

if (existsSync(matches[0])) {
createNodeField({
node,
name: nodeFieldName,
value: matches[0]
});
}
});

/* Check for optional blog post videos and generate node fields. */
Object.keys(optionalBlogPostVideos).forEach(video => {
const { name, nodeFieldName } = optionalBlogPostVideos[video];
const matches = glob.sync(`${dirname(absolutePath)}/${name}.?(mp4|webm)`);
if (existsSync(matches[0])) {
createNodeField({
node,
name: nodeFieldName,
value: matches[0]
});
}
});

createNodeField({
node,
name: `${nodeFields.date.name}`,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 1 addition & 2 deletions content/blog/2019/03/02/query-error-draft-stub/index.mdx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
export const frontmatter = {
contentImages: ["./placeholder.png"],
title: "GraphQL query error draft stub",
introduction: "This file must be kept to prevent GraphQL queries to fail when no blog posts are in the filesystem.",
heroImage: "./placeholder.png",
publishTime: "00:00:00+0000",
coverTitleColor: "",
draft: true
};
1 change: 0 additions & 1 deletion content/docs/query-error-draft-stub/index.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export const frontmatter = {
contentImages: ["./placeholder.png"],
title: "GraphQL query error draft stub",
subline: "This file must be kept to prevent GraphQL queries to fail when no docs pages are in the filesystem.",
draft: true
Expand Down
22 changes: 21 additions & 1 deletion gatsby-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ const dotenv = require("dotenv");
dotenv.config({ path: `./.gatsby/.env.${process.env.NODE_ENV}` });

const { metadataNord, metadataNordDocs } = require("./src/data/project");
const { sourceInstanceTypes } = require("./src/config/internal/nodes");
const {
optionalBlogPostImages,
optionalBlogPostVideos,
requiredBlogPostImages,
sourceInstanceTypes
} = require("./src/config/internal/nodes");
const {
BASE_DIR_CONTENT,
BASE_DIR_ASSETS_IMAGES,
Expand All @@ -41,12 +46,27 @@ const gatsbyPluginRobotsTxtConfig = require("./.gatsby/plugins/robots-txt");
const gatsbyPluginSourceGraphQlConfig = require("./.gatsby/plugins/source-graphql");
const gatsbyPluginMdxConfig = require("./.gatsby/plugins/mdx");

/**
* The Gatsby configuration.
* The `mapping` object includes important mappings related to the optional/required blog post image and video node
* fields.
* It maps the paths (string) to a `File` node to make them available for the "Gatsby Sharp" plugin.
*
* @see https://www.gatsbyjs.org/docs/gatsby-config/#mapping-node-types
*/
module.exports = {
siteMetadata: {
nord: { ...metadataNord },
...metadataNordDocs,
siteUrl: metadataNordDocs.homepage
},
mapping: {
[`Mdx.fields.${optionalBlogPostImages.heroposter.nodeFieldName}`]: "File.absolutePath",
[`Mdx.fields.${optionalBlogPostVideos.hero.nodeFieldName}`]: "File.absolutePath",
[`Mdx.fields.${requiredBlogPostImages.banner.nodeFieldName}`]: "File.absolutePath",
[`Mdx.fields.${requiredBlogPostImages.cover.nodeFieldName}`]: "File.absolutePath",
[`Mdx.fields.${requiredBlogPostImages.hero.nodeFieldName}`]: "File.absolutePath"
},
plugins: [
"gatsby-plugin-styled-components",
"gatsby-plugin-react-helmet",
Expand Down
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"eslint-plugin-react": "7.12.4",
"eslint-plugin-react-hooks": "1.0.2",
"git-revision-webpack-plugin": "3.0.3",
"glob": "7.1.3",
"husky": "1.3.1",
"identity-obj-proxy": "3.0.0",
"jest": "24.1.0",
Expand All @@ -94,7 +95,6 @@
"arctic-ocean-fractal": ">=0.1.0 <1.0.0",
"axios": "0.18.0",
"body-scroll-lock": "2.6.1",
"camelcase": "5.0.0",
"date-fns": "2.0.0-alpha.27",
"gatsby": "2.1.4",
"gatsby-image": "2.0.30",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (C) 2018-present Arctic Ice Studio <[email protected]>
* Copyright (C) 2018-present Sven Greb <[email protected]>
*
* Project: Nord Docs
* Repository: https://github.com/arcticicestudio/nord-docs
* License: MIT
*/

import React from "react";
import PropTypes from "prop-types";

import { contentBlogPostPropTypes } from "data/graphql/fragmentPropTypes";
import { WaveFooter } from "atoms/core/vectors/divider";
import Section, { Content } from "containers/core/Section";
import EmptyState from "molecules/core/EmptyState";

import { BlogPostCard, Grid } from "./styled";
import { emptyStateIllustrationStyles } from "../../shared/styles";

/**
* The component that represents the landing section of the blog posts page.
*
* @author Arctic Ice Studio <[email protected]>
* @author Sven Greb <[email protected]>
* @since 0.3.0
*/
const SectionBlogPostsCardGrid = ({ blogPosts, ...passProps }) => (
<Section {...passProps}>
<Content centered>
{blogPosts.length ? (
<Grid>
{blogPosts.map(
(
{ frontmatter: { title, coverTitleColor }, fields: { bannerImage, coverImage, slugParentRoute, slug } },
idx
) => (
<BlogPostCard
key={title}
blogPostUrl={`${slugParentRoute}${slug}`}
coverTitleColor={coverTitleColor}
fluid={idx === 0 ? bannerImage?.childImageSharp?.fluid : coverImage?.childImageSharp?.fluid}
large={idx === 0}
single={blogPosts.length === 1}
>
{title}
</BlogPostCard>
)
)}
</Grid>
) : (
<EmptyState
headline="Oh, there's nothing here yet"
illustrationStyles={emptyStateIllustrationStyles}
subline="Please check back later, we're working hard on this page!"
/>
)}
</Content>
<WaveFooter />
</Section>
);

SectionBlogPostsCardGrid.propTypes = {
blogPosts: PropTypes.arrayOf(
PropTypes.shape({
...contentBlogPostPropTypes
})
).isRequired
};

export default SectionBlogPostsCardGrid;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Copyright (C) 2018-present Arctic Ice Studio <[email protected]>
* Copyright (C) 2018-present Sven Greb <[email protected]>
*
* Project: Nord Docs
* Repository: https://github.com/arcticicestudio/nord-docs
* License: MIT
*/

export { default } from "./SectionBlogPostsCardGrid";
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright (C) 2018-present Arctic Ice Studio <[email protected]>
* Copyright (C) 2018-present Sven Greb <[email protected]>
*
* Project: Nord Docs
* Repository: https://github.com/arcticicestudio/nord-docs
* License: MIT
*/

import React from "react";
import PropTypes from "prop-types";

import { contentMdxImageFluidPropTypes } from "data/graphql/fragmentPropTypes";
import { A } from "atoms/core/html-elements";

import Card from "./Card";
import Title from "./Title";
import Image from "./Image";
import TitleBox from "./TitleBox";

/**
* The card component to represent a blog post.
*
* @author Arctic Ice Studio <[email protected]>
* @author Sven Greb <[email protected]>
* @since 0.10.0
*/
const BlogPostCard = ({ blogPostUrl, children, coverTitleColor, fluid, large, single, ...passProps }) => (
<Card large={large} single={single} {...passProps}>
<A to={blogPostUrl}>
<Image fluid={fluid} />
<TitleBox>
<Title coverTitleColor={coverTitleColor}>{children}</Title>
</TitleBox>
</A>
</Card>
);

BlogPostCard.propTypes = {
blogPostUrl: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
coverTitleColor: PropTypes.string,
fluid: PropTypes.shape({ ...contentMdxImageFluidPropTypes }).isRequired,
large: PropTypes.bool,
single: PropTypes.bool
};

BlogPostCard.defaultProps = {
coverTitleColor: "",
large: false,
single: false
};

export default BlogPostCard;
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2018-present Arctic Ice Studio <[email protected]>
* Copyright (C) 2018-present Sven Greb <[email protected]>
*
* Project: Nord Docs
* Repository: https://github.com/arcticicestudio/nord-docs
* License: MIT
*/

import styled from "styled-components";

import {
mixinDropShadowAmbientLight,
mixinDropShadowAmbientLightHover,
mixinDropShadowDirectLight,
mixinDropShadowDirectLightHover,
transitionThemedModeSwitch
} from "styles/shared";

/**
* The styled card component to represent a blog post.
*
* @author Arctic Ice Studio <[email protected]>
* @author Sven Greb <[email protected]>
* @since 0.10.0
*/
const Card = styled.article`
position: relative;
border-radius: 12px;
box-shadow: ${mixinDropShadowDirectLight()}, ${mixinDropShadowAmbientLight()};
transition: ${transitionThemedModeSwitch("box-shadow")}, ${transitionThemedModeSwitch("background-color")};
&:hover {
box-shadow: ${mixinDropShadowDirectLightHover()}, ${mixinDropShadowAmbientLightHover()};
}
${({ theme }) => theme.media.tabletPortrait`
grid-column: ${({ large, single }) => large && `auto/span ${single ? 3 : 2}`};
`};
`;

export default Card;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright (C) 2018-present Arctic Ice Studio <[email protected]>
* Copyright (C) 2018-present Sven Greb <[email protected]>
*
* Project: Nord Docs
* Repository: https://github.com/arcticicestudio/nord-docs
* License: MIT
*/

import styled from "styled-components";
import GatsbyImage from "gatsby-image";

import { BLOG_POST_IMAGE_MIN_HEIGHT } from "config/internal/constants";
import { transitionThemedModeSwitch } from "styles/shared";

/**
* An styled Gatsby image.
*
* @author Arctic Ice Studio <[email protected]>
* @author Sven Greb <[email protected]>
* @since 0.10.0
* @see https://www.gatsbyjs.org/docs/working-with-images
*/
const Image = styled(GatsbyImage)`
min-height: ${BLOG_POST_IMAGE_MIN_HEIGHT / 2}px;
max-height: ${BLOG_POST_IMAGE_MIN_HEIGHT / 2}px;
border-radius: 12px;
overflow: hidden;
transition: ${transitionThemedModeSwitch("box-shadow")};
user-select: none;
pointer-events: none;
`;

export default Image;
Loading

0 comments on commit 6d48c68

Please sign in to comment.