From 6d48c682edad524b9b77ff728ba108764c4d24c4 Mon Sep 17 00:00:00 2001 From: Arctic Ice Studio Date: Sat, 9 Mar 2019 21:13:03 +0100 Subject: [PATCH] Implement blog post grid and card components, GraphQL nodes and queries 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) https://github.com/gatsbyjs/gatsby/issues/3344 (5) https://www.gatsbyjs.org/blog/2019-03-04-new-schema-customization GH-129 --- .gatsby/createPages.js | 3 +- .gatsby/onCreateNode.js | 57 +++++++++++++- .../03/02/query-error-draft-stub/banner.png | Bin 0 -> 107 bytes .../03/02/query-error-draft-stub/cover.png | Bin 0 -> 107 bytes .../03/02/query-error-draft-stub/hero.mp4 | 0 .../03/02/query-error-draft-stub/hero.png | Bin 0 -> 107 bytes .../02/query-error-draft-stub/heroposter.png | Bin 0 -> 107 bytes .../03/02/query-error-draft-stub/index.mdx | 3 +- content/docs/query-error-draft-stub/index.mdx | 1 - gatsby-config.js | 22 +++++- package-lock.json | 3 +- package.json | 2 +- .../SectionBlogPostsCardGrid.jsx | 71 ++++++++++++++++++ .../blog/SectionBlogPostsCardGrid/index.js | 10 +++ .../styled/BlogPostCard/BlogPostCard.jsx | 54 +++++++++++++ .../styled/BlogPostCard/Card.jsx | 42 +++++++++++ .../styled/BlogPostCard/Image.jsx | 34 +++++++++ .../styled/BlogPostCard/Title.jsx | 27 +++++++ .../styled/BlogPostCard/TitleBox.jsx | 26 +++++++ .../SectionBlogPostsCardGrid/styled/Grid.jsx | 34 +++++++++ .../SectionBlogPostsCardGrid/styled/index.js | 13 ++++ src/components/organisms/page/blog/index.js | 4 +- src/components/templates/blog/BlogPost.jsx | 49 +++++++----- src/components/templates/docs/DocsPage.jsx | 40 ++++++---- src/components/templates/shared/propTypes.js | 32 +++++--- src/config/internal/constants.js | 36 +++++++++ src/config/internal/nodes.js | 40 +++++++++- src/data/graphql/fragmentPropTypes.js | 17 +++-- src/data/graphql/fragments.js | 53 +++++++++---- src/pages/blog.jsx | 46 ++++++++++-- 30 files changed, 633 insertions(+), 86 deletions(-) create mode 100644 content/blog/2019/03/02/query-error-draft-stub/banner.png create mode 100644 content/blog/2019/03/02/query-error-draft-stub/cover.png create mode 100755 content/blog/2019/03/02/query-error-draft-stub/hero.mp4 create mode 100644 content/blog/2019/03/02/query-error-draft-stub/hero.png create mode 100644 content/blog/2019/03/02/query-error-draft-stub/heroposter.png create mode 100644 src/components/organisms/page/blog/SectionBlogPostsCardGrid/SectionBlogPostsCardGrid.jsx create mode 100644 src/components/organisms/page/blog/SectionBlogPostsCardGrid/index.js create mode 100644 src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/BlogPostCard.jsx create mode 100644 src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Card.jsx create mode 100644 src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Image.jsx create mode 100644 src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Title.jsx create mode 100644 src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/TitleBox.jsx create mode 100644 src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/Grid.jsx create mode 100644 src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/index.js diff --git a/.gatsby/createPages.js b/.gatsby/createPages.js index 9133435b..4cb1e5bf 100644 --- a/.gatsby/createPages.js +++ b/.gatsby/createPages.js @@ -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; @@ -97,6 +97,7 @@ const createPages = async ({ graphql, actions }) => { contentSourceType, date, id, + publishTime, relativeDirectory, slug, slugParentRoute diff --git a/.gatsby/onCreateNode.js b/.gatsby/onCreateNode.js index 341453c1..7994f0e2 100644 --- a/.gatsby/onCreateNode.js +++ b/.gatsby/onCreateNode.js @@ -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"); @@ -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); @@ -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}`, diff --git a/content/blog/2019/03/02/query-error-draft-stub/banner.png b/content/blog/2019/03/02/query-error-draft-stub/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..2545595865642bbfce2ea274eadd978df7a99126 GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blwj^(N7Y02B69$J%JZi^)BAf*t yk;M!QddeWoSh3W;3@FI$>Eal|aXmSKfr){Em4We*8tZJJFoUP7pUXO@geCx-m=lZu literal 0 HcmV?d00001 diff --git a/content/blog/2019/03/02/query-error-draft-stub/cover.png b/content/blog/2019/03/02/query-error-draft-stub/cover.png new file mode 100644 index 0000000000000000000000000000000000000000..2545595865642bbfce2ea274eadd978df7a99126 GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blwj^(N7Y02B69$J%JZi^)BAf*t yk;M!QddeWoSh3W;3@FI$>Eal|aXmSKfr){Em4We*8tZJJFoUP7pUXO@geCx-m=lZu literal 0 HcmV?d00001 diff --git a/content/blog/2019/03/02/query-error-draft-stub/hero.mp4 b/content/blog/2019/03/02/query-error-draft-stub/hero.mp4 new file mode 100755 index 00000000..e69de29b diff --git a/content/blog/2019/03/02/query-error-draft-stub/hero.png b/content/blog/2019/03/02/query-error-draft-stub/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..2545595865642bbfce2ea274eadd978df7a99126 GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blwj^(N7Y02B69$J%JZi^)BAf*t yk;M!QddeWoSh3W;3@FI$>Eal|aXmSKfr){Em4We*8tZJJFoUP7pUXO@geCx-m=lZu literal 0 HcmV?d00001 diff --git a/content/blog/2019/03/02/query-error-draft-stub/heroposter.png b/content/blog/2019/03/02/query-error-draft-stub/heroposter.png new file mode 100644 index 0000000000000000000000000000000000000000..2545595865642bbfce2ea274eadd978df7a99126 GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blwj^(N7Y02B69$J%JZi^)BAf*t yk;M!QddeWoSh3W;3@FI$>Eal|aXmSKfr){Em4We*8tZJJFoUP7pUXO@geCx-m=lZu literal 0 HcmV?d00001 diff --git a/content/blog/2019/03/02/query-error-draft-stub/index.mdx b/content/blog/2019/03/02/query-error-draft-stub/index.mdx index 05c38c03..0844feb1 100644 --- a/content/blog/2019/03/02/query-error-draft-stub/index.mdx +++ b/content/blog/2019/03/02/query-error-draft-stub/index.mdx @@ -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 }; diff --git a/content/docs/query-error-draft-stub/index.mdx b/content/docs/query-error-draft-stub/index.mdx index 88e56548..3d1d6fbb 100644 --- a/content/docs/query-error-draft-stub/index.mdx +++ b/content/docs/query-error-draft-stub/index.mdx @@ -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 diff --git a/gatsby-config.js b/gatsby-config.js index facf4c7f..6a2edd38 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -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, @@ -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", diff --git a/package-lock.json b/package-lock.json index a82d7266..e6c3643c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3397,7 +3397,8 @@ "camelcase": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", - "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==" + "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", + "dev": true }, "camelcase-keys": { "version": "2.1.0", diff --git a/package.json b/package.json index 74fcb68c..1cf1a5ac 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/components/organisms/page/blog/SectionBlogPostsCardGrid/SectionBlogPostsCardGrid.jsx b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/SectionBlogPostsCardGrid.jsx new file mode 100644 index 00000000..69d99c97 --- /dev/null +++ b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/SectionBlogPostsCardGrid.jsx @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * 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 + * @author Sven Greb + * @since 0.3.0 + */ +const SectionBlogPostsCardGrid = ({ blogPosts, ...passProps }) => ( +
+ + {blogPosts.length ? ( + + {blogPosts.map( + ( + { frontmatter: { title, coverTitleColor }, fields: { bannerImage, coverImage, slugParentRoute, slug } }, + idx + ) => ( + + {title} + + ) + )} + + ) : ( + + )} + + +
+); + +SectionBlogPostsCardGrid.propTypes = { + blogPosts: PropTypes.arrayOf( + PropTypes.shape({ + ...contentBlogPostPropTypes + }) + ).isRequired +}; + +export default SectionBlogPostsCardGrid; diff --git a/src/components/organisms/page/blog/SectionBlogPostsCardGrid/index.js b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/index.js new file mode 100644 index 00000000..0ed3c6b7 --- /dev/null +++ b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/index.js @@ -0,0 +1,10 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +export { default } from "./SectionBlogPostsCardGrid"; diff --git a/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/BlogPostCard.jsx b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/BlogPostCard.jsx new file mode 100644 index 00000000..22e070b6 --- /dev/null +++ b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/BlogPostCard.jsx @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * 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 + * @author Sven Greb + * @since 0.10.0 + */ +const BlogPostCard = ({ blogPostUrl, children, coverTitleColor, fluid, large, single, ...passProps }) => ( + + + + + {children} + + + +); + +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; diff --git a/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Card.jsx b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Card.jsx new file mode 100644 index 00000000..6be4126a --- /dev/null +++ b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Card.jsx @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * 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 + * @author Sven Greb + * @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; diff --git a/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Image.jsx b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Image.jsx new file mode 100644 index 00000000..28e2ebe3 --- /dev/null +++ b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Image.jsx @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * 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 + * @author Sven Greb + * @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; diff --git a/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Title.jsx b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Title.jsx new file mode 100644 index 00000000..6c62e575 --- /dev/null +++ b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/Title.jsx @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +import styled from "styled-components"; + +import { colors, ms, MODE_DARK_NIGHT_FROST } from "styles/theme"; +import { H4 } from "atoms/core/html-elements"; + +/** + * The styled title of a blog post card. + * + * @author Arctic Ice Studio + * @author Sven Greb + * @since 0.10.0 + */ +const Title = styled(H4)` + font-size: ${ms(5)}; + color: ${({ coverTitleColor }) => coverTitleColor || colors.font.base[MODE_DARK_NIGHT_FROST]}; +`; + +export default Title; diff --git a/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/TitleBox.jsx b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/TitleBox.jsx new file mode 100644 index 00000000..016961e5 --- /dev/null +++ b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/BlogPostCard/TitleBox.jsx @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +import styled from "styled-components"; + +/** + * The styled container for the title of a blog post. + * + * @author Arctic Ice Studio + * @author Sven Greb + * @since 0.10.0 + */ +const TitleBox = styled.div` + position: absolute; + bottom: 0; + left: 0; + padding: 0.5em 2em; +`; + +export default TitleBox; diff --git a/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/Grid.jsx b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/Grid.jsx new file mode 100644 index 00000000..565ac58e --- /dev/null +++ b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/Grid.jsx @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +import styled from "styled-components"; + +/** + * The grid container for blog post cards. + * + * @author Arctic Ice Studio + * @author Sven Greb + * @since 0.10.0 + */ +const Grid = styled.div` + display: grid; + grid-template-columns: 1fr; + grid-gap: 2.5em; + max-width: 100%; + + ${({ theme }) => theme.media.tabletPortrait` + grid-template-columns: repeat(2, 1fr); + `}; + + ${({ theme }) => theme.media.tabletLandscape` + grid-template-columns: repeat(3, 1fr); + `}; +`; + +export default Grid; diff --git a/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/index.js b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/index.js new file mode 100644 index 00000000..1cb9a915 --- /dev/null +++ b/src/components/organisms/page/blog/SectionBlogPostsCardGrid/styled/index.js @@ -0,0 +1,13 @@ +/* + * Copyright (C) 2018-present Arctic Ice Studio + * Copyright (C) 2018-present Sven Greb + * + * Project: Nord Docs + * Repository: https://github.com/arcticicestudio/nord-docs + * License: MIT + */ + +import BlogPostCard from "./BlogPostCard/BlogPostCard"; +import Grid from "./Grid"; + +export { BlogPostCard, Grid }; diff --git a/src/components/organisms/page/blog/index.js b/src/components/organisms/page/blog/index.js index 64aa75e5..6990dee9 100644 --- a/src/components/organisms/page/blog/index.js +++ b/src/components/organisms/page/blog/index.js @@ -14,7 +14,7 @@ * @since 0.3.0 */ -import SectionBlogPosts from "./SectionBlogPosts"; +import SectionBlogPostsCardGrid from "./SectionBlogPostsCardGrid"; /* eslint-disable-next-line import/prefer-default-export */ -export { SectionBlogPosts }; +export { SectionBlogPostsCardGrid }; diff --git a/src/components/templates/blog/BlogPost.jsx b/src/components/templates/blog/BlogPost.jsx index a948d446..e4b416c9 100644 --- a/src/components/templates/blog/BlogPost.jsx +++ b/src/components/templates/blog/BlogPost.jsx @@ -11,7 +11,6 @@ import React from "react"; import MDXRenderer from "gatsby-mdx/mdx-renderer"; import { MDXProvider } from "@mdx-js/tag"; import { graphql } from "gatsby"; -import camelCase from "camelcase"; import BaseLayout from "layouts/core/BaseLayout"; import Section, { Content } from "containers/core/Section"; @@ -30,26 +29,22 @@ import { blogPostTemplatePropTypes } from "../shared/propTypes"; * @see https://github.com/ChristopherBiscardi/gatsby-mdx * */ -const BlogPost = ({ data: { mdx, images }, location: { pathname }, ...passProps }) => { +const BlogPost = ({ data: { images, mdx, videos }, location: { pathname }, ...passProps }) => { /* - * Make each image available as image prop in camelcase format consisting of the name and extension of the file. - * Example: "snow-mountain.png" -> "snowMountainPng" + * Generate mappings for content images and videos to allow to use them by their file names. + * Examples: + * + * - `prop.images["snow-mountain.png"]` + * - `prop.videos["arctic-owl.mp4"]` */ const blogPostImages = {}; - images?.edges.forEach(({ node }) => { - const { extension, name } = node; - const imageName = camelCase(`${name}.${extension}`); - blogPostImages[imageName] = node.childImageSharp; + images?.edges?.forEach(({ node: { childImageSharp } }) => { + blogPostImages[childImageSharp.fluid.originalName] = childImageSharp.fluid; + }); + const blogPostVideos = {}; + videos?.edges?.forEach(({ node }) => { + blogPostVideos[`${node.name}.${node.extension}`] = node.publicURL; }); - - /* - * Shorten the object key path for content image props by deconstructing the "childImageSharp" property. - * The images can then be used by their index: `props.contentImages[0].fluid` - */ - const blogPostContentImages = []; - if (mdx.frontmatter?.contentImages) { - mdx.frontmatter.contentImages.forEach(({ childImageSharp }) => blogPostContentImages.push(childImageSharp)); - } return ( @@ -58,9 +53,13 @@ const BlogPost = ({ data: { mdx, images }, location: { pathname }, ...passProps {mdx.code.body} @@ -95,7 +94,17 @@ export const pageQuery = graphql` body } id - ...contentBlogPostFrontmatter + ...contentBlogPost + } + videos: allFile( + filter: { relativeDirectory: { eq: $relativeDirectory }, extension: { regex: "/(mp4|webm)/" } } + sort: { fields: [name], order: ASC } + ) { + edges { + node { + ...contentBlogPostMediaFile + } + } } } `; diff --git a/src/components/templates/docs/DocsPage.jsx b/src/components/templates/docs/DocsPage.jsx index aaff5ded..2305a577 100644 --- a/src/components/templates/docs/DocsPage.jsx +++ b/src/components/templates/docs/DocsPage.jsx @@ -11,7 +11,6 @@ import React from "react"; import MDXRenderer from "gatsby-mdx/mdx-renderer"; import { MDXProvider } from "@mdx-js/tag"; import { graphql } from "gatsby"; -import camelCase from "camelcase"; import { WaveFooter } from "atoms/core/vectors/divider"; import BaseLayout from "layouts/core/BaseLayout"; @@ -32,23 +31,22 @@ import PageTypoHead from "../shared/PageTypoHead"; * @see https://github.com/ChristopherBiscardi/gatsby-mdx * */ -const DocsPage = ({ data: { mdx, images }, location: { pathname }, ...passProps }) => { +const DocsPage = ({ data: { images, mdx, videos }, location: { pathname }, ...passProps }) => { /* - * Make each image available as image prop in camelcase format consisting of the name and extension of the file. - * Example: "snow-mountain.png" -> "snowMountainPng" + * Generate mappings for content images and videos to allow to use them by their file names. + * Examples: + * + * - `prop.images["snow-mountain.png"]` + * - `prop.videos["arctic-owl.mp4"]` */ - const blogPostImages = {}; - images?.edges.forEach(({ node }) => { - const { extension, name } = node; - const imageName = camelCase(`${name}.${extension}`); - blogPostImages[imageName] = node.childImageSharp; + const docsPageImages = {}; + images?.edges?.forEach(({ node: { childImageSharp } }) => { + docsPageImages[childImageSharp.fluid.originalName] = childImageSharp.fluid; + }); + const docsPageVideos = {}; + videos?.edges?.forEach(({ node }) => { + docsPageVideos[`${node.name}.${node.extension}`] = node.publicURL; }); - - /* Shorten the object key path for content image props by deconstructing the "childImageSharp" property. */ - const blogPostContentImages = []; - if (mdx.frontmatter?.contentImages) { - mdx.frontmatter.contentImages.forEach(({ childImageSharp }) => blogPostContentImages.push(childImageSharp)); - } return ( @@ -57,7 +55,7 @@ const DocsPage = ({ data: { mdx, images }, location: { pathname }, ...passProps - + {mdx.code.body} @@ -94,6 +92,16 @@ export const pageQuery = graphql` id ...contentDocsPageFrontmatter } + videos: allFile( + filter: { relativeDirectory: { eq: $relativeDirectory }, extension: { regex: "/(mp4|webm)/" } } + sort: { fields: [name], order: ASC } + ) { + edges { + node { + ...contentBlogPostMediaFile + } + } + } } `; diff --git a/src/components/templates/shared/propTypes.js b/src/components/templates/shared/propTypes.js index baf41443..c7371ad9 100644 --- a/src/components/templates/shared/propTypes.js +++ b/src/components/templates/shared/propTypes.js @@ -20,20 +20,21 @@ import PropTypes from "prop-types"; import { contentBlogPostFrontmatterPropTypes, contentDocsPageFrontmatterPropTypes, - contentMdxImageFluidPropTypes + contentMdxImageFluidPropTypes, + contentMdxMediaFilePropTypes } from "data/graphql/fragmentPropTypes"; +const imagePropTypes = { + childImageSharp: PropTypes.shape({ + ...contentMdxImageFluidPropTypes + }), + extension: PropTypes.string, + name: PropTypes.string +}; + const dataImagesPropTypes = { images: PropTypes.shape({ - edges: PropTypes.arrayOf( - PropTypes.shape({ - childImageSharp: PropTypes.shape({ - ...contentMdxImageFluidPropTypes - }), - extension: PropTypes.string, - name: PropTypes.string - }) - ) + edges: PropTypes.arrayOf(PropTypes.shape({ ...imagePropTypes })) }) }; @@ -44,9 +45,20 @@ const dataMDXPropTypes = { id: PropTypes.string }; +const dataVideosPropTypes = { + videos: PropTypes.shape({ + edges: PropTypes.arrayOf( + PropTypes.shape({ + ...contentMdxMediaFilePropTypes + }) + ) + }) +}; + const blogPostTemplatePropTypes = { data: PropTypes.shape({ ...dataImagesPropTypes, + ...dataVideosPropTypes, mdx: PropTypes.shape({ ...dataMDXPropTypes, ...contentBlogPostFrontmatterPropTypes diff --git a/src/config/internal/constants.js b/src/config/internal/constants.js index fe4c8fd7..b754f14f 100644 --- a/src/config/internal/constants.js +++ b/src/config/internal/constants.js @@ -94,6 +94,38 @@ const BASE_DIR_CONFIG = `${BASE_DIR_SRC}/config`; */ const BASE_DIR_PAGES = `${BASE_DIR_SRC}/pages`; +/** + * The minimum height for required blog post images. + * + * @constant {number} + * @since 0.10.0 + */ +const BLOG_POST_IMAGE_MIN_HEIGHT = 920; + +/** + * The minimum width for blog post banner images. + * + * @constant {number} + * @since 0.10.0 + */ +const BLOG_POST_IMAGE_BANNER_MIN_WIDTH = BLOG_POST_IMAGE_MIN_HEIGHT * 1.7; + +/** + * The minimum width for blog post cover images. + * + * @constant {number} + * @since 0.10.0 + */ +const BLOG_POST_IMAGE_COVER_MIN_WIDTH = BLOG_POST_IMAGE_MIN_HEIGHT * 0.85; + +/** + * The minimum width for blog post hero images. + * + * @constant {number} + * @since 0.10.0 + */ +const BLOG_POST_IMAGE_HERO_MIN_WIDTH = BLOG_POST_IMAGE_MIN_HEIGHT * 1.8; + /** * The internal type for MDX nodes. * @@ -121,6 +153,10 @@ module.exports = { BASE_DIR_CONFIG, BASE_DIR_CONTENT, BASE_DIR_PAGES, + BLOG_POST_IMAGE_MIN_HEIGHT, + BLOG_POST_IMAGE_BANNER_MIN_WIDTH, + BLOG_POST_IMAGE_COVER_MIN_WIDTH, + BLOG_POST_IMAGE_HERO_MIN_WIDTH, NODE_TYPE_MDX, REGEX_BLOG_POST_DATE }; diff --git a/src/config/internal/nodes.js b/src/config/internal/nodes.js index 9bb498f4..16a02adf 100644 --- a/src/config/internal/nodes.js +++ b/src/config/internal/nodes.js @@ -14,6 +14,38 @@ * @since 0.1.0 */ +/** + * The names and node field names of the optional videos for a blog post. + * + * @type {Object} + * @since 0.10.0 + */ +const optionalBlogPostImages = { + heroposter: { name: "heroposter", nodeFieldName: "heroVideoPoster" } +}; + +/** + * The names and node field names of the optional videos for a blog post. + * + * @type {Object} + * @since 0.10.0 + */ +const optionalBlogPostVideos = { + hero: { name: "hero", nodeFieldName: "heroVideo" } +}; + +/** + * The names and node field names of the required images for a blog post. + * + * @type {Object} + * @since 0.10.0 + */ +const requiredBlogPostImages = { + banner: { name: "banner", nodeFieldName: "bannerImage" }, + cover: { name: "cover", nodeFieldName: "coverImage" }, + hero: { name: "hero", nodeFieldName: "heroImage" } +}; + /** * The names of the source instance types and their paths relative to the project root path. * @@ -77,4 +109,10 @@ const nodeFields = { } }; -module.exports = { nodeFields, sourceInstanceTypes }; +module.exports = { + nodeFields, + optionalBlogPostImages, + optionalBlogPostVideos, + requiredBlogPostImages, + sourceInstanceTypes +}; diff --git a/src/data/graphql/fragmentPropTypes.js b/src/data/graphql/fragmentPropTypes.js index e71dc920..f09c5215 100644 --- a/src/data/graphql/fragmentPropTypes.js +++ b/src/data/graphql/fragmentPropTypes.js @@ -53,9 +53,6 @@ const contentMdxMediaFilePropTypes = { */ const contentMdxDocumentFrontmatterPropTypes = { contentImages: PropTypes.arrayOf( - PropTypes.shape({ - ...contentMdxImageFluidPropTypes - }), PropTypes.shape({ ...contentMdxImageFluidPropTypes }) @@ -69,8 +66,9 @@ const contentMdxDocumentFrontmatterPropTypes = { */ const contentBlogPostFrontmatterPropTypes = { frontmatter: PropTypes.shape({ - introduction: PropTypes.string, - publishTime: PropTypes.string, + coverTitleColor: PropTypes.string, + introduction: PropTypes.string.isRequired, + publishTime: PropTypes.string.isRequired, ...contentMdxDocumentFrontmatterPropTypes }) }; @@ -101,6 +99,15 @@ const contentBlogPostFieldsPropTypes = { fields: PropTypes.shape({ date: PropTypes.string, relativeDirectory: PropTypes.string, + bannerImage: PropTypes.shape({ + ...contentMdxImageFluidPropTypes + }).isRequired, + coverImage: PropTypes.shape({ + ...contentMdxImageFluidPropTypes + }).isRequired, + heroImage: PropTypes.shape({ + ...contentMdxImageFluidPropTypes + }).isRequired, ...contentNodeFieldsPropTypes }) }; diff --git a/src/data/graphql/fragments.js b/src/data/graphql/fragments.js index 1ff719a4..1a4f1d87 100644 --- a/src/data/graphql/fragments.js +++ b/src/data/graphql/fragments.js @@ -23,7 +23,7 @@ import { graphql } from "gatsby"; */ export const gqlFragmentContentMdxDocumentImageFluid = graphql` fragment contentMdxDocumentImageFluid on ImageSharp { - fluid(maxWidth: 2560, quality: 90) { + fluid(maxWidth: 2560, quality: 100) { originalImg originalName presentationHeight @@ -33,23 +33,26 @@ export const gqlFragmentContentMdxDocumentImageFluid = graphql` } `; +/** + * GraphQL fragment for blog post media files. + */ +export const gqlFragmentContentBlogPostMediaFile = graphql` + fragment contentBlogPostMediaFile on File { + extension + name + publicURL + relativePath + } +`; + /** * GraphQL fragment for the frontmatter fields of an MDX blog post. */ export const gqlFragmentContentBlogPostFrontmatter = graphql` fragment contentBlogPostFrontmatter on Mdx { frontmatter { - contentImages { - childImageSharp { - ...contentMdxDocumentImageFluid - } - } + coverTitleColor draft - heroImage { - childImageSharp { - ...contentMdxDocumentImageFluid - } - } introduction publishTime title @@ -63,11 +66,6 @@ export const gqlFragmentContentBlogPostFrontmatter = graphql` export const gqlFragmentContentDocsPageFrontmatter = graphql` fragment contentDocsPageFrontmatter on Mdx { frontmatter { - contentImages { - childImageSharp { - ...contentMdxDocumentImageFluid - } - } draft subline title @@ -81,6 +79,29 @@ export const gqlFragmentContentDocsPageFrontmatter = graphql` export const gqlFragmentContentBlogPostFields = graphql` fragment contentBlogPostFields on Mdx { fields { + bannerImage { + childImageSharp { + ...contentMdxDocumentImageFluid + } + } + coverImage { + childImageSharp { + ...contentMdxDocumentImageFluid + } + } + heroImage { + childImageSharp { + ...contentMdxDocumentImageFluid + } + } + heroVideo { + ...contentBlogPostMediaFile + } + heroVideoPoster { + childImageSharp { + ...contentMdxDocumentImageFluid + } + } contentSourceType date relativeDirectory diff --git a/src/pages/blog.jsx b/src/pages/blog.jsx index fca171d0..072b6fee 100644 --- a/src/pages/blog.jsx +++ b/src/pages/blog.jsx @@ -8,10 +8,13 @@ */ import React from "react"; +import PropTypes from "prop-types"; +import { graphql } from "gatsby"; +import { contentBlogPostPropTypes } from "data/graphql/fragmentPropTypes"; import { locationPropTypes } from "data/pages/shared/propTypes"; import BaseLayout from "layouts/core/BaseLayout"; -import { SectionBlogPosts } from "organisms/page/blog"; +import { SectionBlogPostsCardGrid } from "organisms/page/blog"; /** * The component that represents the blog page. @@ -20,12 +23,41 @@ import { SectionBlogPosts } from "organisms/page/blog"; * @author Sven Greb * @since 0.3.0 */ -const Blog = ({ location: { pathname } }) => ( - - - -); +const Blog = ({ data, location: { pathname } }) => { + const blogPosts = data?.allBlogPosts?.edges?.map(({ node }) => ({ ...node })) ?? []; + return ( + + + + ); +}; -Blog.propTypes = locationPropTypes; +Blog.propTypes = { + data: PropTypes.shape({ + allBlogPosts: PropTypes.shape({ + edges: PropTypes.arrayOf( + PropTypes.shape({ + ...contentBlogPostPropTypes + }) + ) + }) + }).isRequired, + ...locationPropTypes +}; + +export const pageQuery = graphql` + { + allBlogPosts: allMdx( + filter: { fields: { contentSourceType: { eq: "blog" } }, frontmatter: { draft: { ne: true } } } + sort: { fields: [fields___date, frontmatter___publishTime], order: DESC } + ) { + edges { + node { + ...contentBlogPost + } + } + } + } +`; export default Blog;