1
0
mirror of https://github.com/kremalicious/blog.git synced 2024-06-28 16:48:00 +02:00

Merge pull request #63 from kremalicious/feature/related-posts

related posts based on tags & pagination
This commit is contained in:
Matthias Kretschmann 2018-09-29 21:35:47 +02:00 committed by GitHub
commit 0497764f2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 384 additions and 106 deletions

View File

@ -16,16 +16,38 @@
## Table of Contents ## Table of Contents
- [🎉 Features](#-features)
- [🎆 EXIF extraction](#-exif-extraction)
- [🕸 Related Posts](#-related-posts)
- [🏆 SEO component](#-seo-component)
- [📈 Matomo (formerly Piwik) analytics tracking](#-matomo-formerly-piwik-analytics-tracking)
- [gatsby-redirect-from](#-gatsby-redirect-from)
- [💎 Importing SVG assets](#-importing-svg-assets)
- [🍬 Typekit component](#-typekit-component)
- [✨ Development](#-development)
- [🔮 Linting](#-linting)
- [🎈 Add a new project](#-add-a-new-project)
- [🚚 Deployment](#-deployment)
- [🏛 Licenses](#-licenses)
- [Posts](#-posts)
- [Photos & images](#-photos-images)
--- ---
## 🎉 Features ## 🎉 Features
The whole [blog](https://kremalicious.com) is a React-based Single Page App built with [Gatsby v2](https://www.gatsbyjs.org). The whole [blog](https://kremalicious.com) is a React-based Single Page App built with [Gatsby v2](https://www.gatsbyjs.org).
### EXIF extraction ### 🎆 EXIF extraction
... ...
### 🕸 Related Posts
Under each post a list of related posts is displayed which are based on the tags of the currently viewed post. Also allows loading more related posts in place.
If you want to know how, have a look at the respective component under [`src/components/molecules/Pagination.jsx`](src/components/molecules/Pagination.jsx)
### 🏆 SEO component ### 🏆 SEO component
Includes a SEO component which automatically switches all required `meta` tags for search engines, Twitter Cards, and Facebook OpenGraph tags based on the browsed route/page. Includes a SEO component which automatically switches all required `meta` tags for search engines, Twitter Cards, and Facebook OpenGraph tags based on the browsed route/page.

View File

@ -10,10 +10,10 @@ updated: 2018-07-11 00:52:46+02:00
featured: true featured: true
tags: tags:
- goodies
- tutorial - tutorial
- tor - tor
- macos - macos
- goodies
coinhive: true coinhive: true
--- ---

View File

@ -2,7 +2,6 @@ const path = require('path')
const fs = require('fs') const fs = require('fs')
const yaml = require('js-yaml') const yaml = require('js-yaml')
const { createFilePath } = require('gatsby-source-filesystem') const { createFilePath } = require('gatsby-source-filesystem')
const { paginate } = require('gatsby-awesome-pagination')
const fastExif = require('fast-exif') const fastExif = require('fast-exif')
const Fraction = require('fraction.js') const Fraction = require('fraction.js')
const dms2dec = require('dms2dec') const dms2dec = require('dms2dec')
@ -172,12 +171,13 @@ exports.createPages = ({ graphql, actions }) => {
} }
const posts = result.data.allMarkdownRemark.edges const posts = result.data.allMarkdownRemark.edges
const numPages = Math.ceil(posts.length / itemsPerPage)
// Generate posts & posts index // Generate posts & posts index
generateContent(createPage, posts) generateContent(createPage, posts, numPages)
// Generate Tag Pages // Generate Tag Pages
generateTagPages(createPage, posts) generateTagPages(createPage, posts, numPages)
// create manual redirects // create manual redirects
redirects.forEach(({ f, t }) => { redirects.forEach(({ f, t }) => {
@ -196,7 +196,7 @@ exports.createPages = ({ graphql, actions }) => {
const postsTemplate = path.resolve('src/templates/Posts.jsx') const postsTemplate = path.resolve('src/templates/Posts.jsx')
const generateContent = (createPage, posts) => { const generateContent = (createPage, posts, numPages) => {
const postTemplate = path.resolve('src/templates/Post.jsx') const postTemplate = path.resolve('src/templates/Post.jsx')
// Create Post pages // Create Post pages
@ -210,69 +210,49 @@ const generateContent = (createPage, posts) => {
}) })
}) })
// Create paginated front page // Create paginated Blog index pages
paginate({ Array.from({ length: numPages }).forEach((_, i) => {
createPage, createPage({
items: posts, path: i === 0 ? '/' : `/page/${i + 1}`,
itemsPerPage: itemsPerPage, component: postsTemplate,
pathPrefix: '/', context: {
component: postsTemplate limit: itemsPerPage,
skip: i * itemsPerPage,
numPages,
currentPageNumber: i + 1,
prevPage: i - 1,
nextPage: i + 2
}
})
}) })
} }
const generateTagPages = (createPage, posts) => { const generateTagPages = (createPage, posts) => {
const tagSet = new Set() const tagList = arrayReducer(posts, 'tags')
const tagMap = new Map()
posts.forEach(post => {
if (post.node.frontmatter.tags) {
post.node.frontmatter.tags.forEach(tag => {
tagSet.add(tag)
const array = tagMap.has(tag) ? tagMap.get(tag) : []
array.push(post)
tagMap.set(tag, array)
})
}
})
const tagList = Array.from(tagSet)
tagList.forEach(tag => { tagList.forEach(tag => {
if (tag === 'goodies') return if (tag === 'goodies') return
// Create tag pages // Create tag pages
createPage({ createPage({
path: `/tag/${tag}/`, path: `/tags/${tag}/`,
component: postsTemplate, component: postsTemplate,
context: { tag } context: { tag }
}) })
}) })
// Object.keys(posts).forEach(tagName => {
// const pageSize = 5
// const pagesSum = Math.ceil(posts[tagName].length / pageSize)
// for (let page = 1; page <= pagesSum; page++) {
// createPage({
// path:
// page === 1
// ? `/tag/${tagName.toLowerCase()}`
// : `/tag/${tagName.toLowerCase()}/page/${page}`,
// component: postsTemplate,
// context: {
// posts: paginate(posts[tagName], pageSize, page),
// tag: tagName,
// pagesSum,
// page
// }
// })
// }
// })
} }
// function paginate(array, page_size, page_number) { // https://www.adamjberkowitz.com/tags-and-categories-in-gatsby-js/
// return array const arrayReducer = (postsArray, type) => {
// .slice(0) return (postsArray = postsArray
// .slice((page_number - 1) * page_size, page_number * page_size) .map(({ node }) => {
// } return node.frontmatter[type]
})
.reduce((a, b) => {
return a.concat(b)
}, [])
.filter((type, index, array) => {
return array.indexOf(type) === index
})
.sort())
}

View File

@ -28,7 +28,6 @@
"fast-exif": "^1.0.1", "fast-exif": "^1.0.1",
"fraction.js": "^4.0.9", "fraction.js": "^4.0.9",
"gatsby": "^2.0.12", "gatsby": "^2.0.12",
"gatsby-awesome-pagination": "^0.3.1",
"gatsby-image": "^2.0.12", "gatsby-image": "^2.0.12",
"gatsby-plugin-catch-links": "^2.0.3", "gatsby-plugin-catch-links": "^2.0.3",
"gatsby-plugin-favicon": "^3.1.4", "gatsby-plugin-favicon": "^3.1.4",

View File

@ -29,4 +29,12 @@ export const imageSizeDefault = graphql`
} }
` `
export const imageSizeThumb = graphql`
fragment ImageFluidThumb on ImageSharp {
fluid(maxWidth: 200, maxHeight: 85, quality: 85, cropFocus: CENTER) {
...GatsbyImageSharpFluid_withWebp_noBase64
}
}
`
export default Image export default Image

View File

@ -27,15 +27,10 @@
@include ellipsis(); @include ellipsis();
width: 100%; width: 100%;
text-align: center;
color: $text-color; color: $text-color;
font-family: $font-family-base; font-family: $font-family-base;
font-size: $font-size-small; font-size: $font-size-small;
padding: ($spacer/4) 0; padding: ($spacer/4) 0;
max-width: 70%; margin-top: -($spacer);
margin: -($spacer) auto $spacer auto; margin-bottom: $spacer;
@media (min-width: $screen-sm) {
max-width: 50%;
}
} }

View File

@ -16,9 +16,7 @@ const query = graphql`
title title
image { image {
childImageSharp { childImageSharp {
fluid(maxWidth: 300, maxHeight: 130, cropFocus: CENTER) { ...ImageFluidThumb
...GatsbyImageSharpFluid_withWebp_noBase64
}
} }
} }
} }

View File

@ -3,25 +3,61 @@ import PropTypes from 'prop-types'
import { Link } from 'gatsby' import { Link } from 'gatsby'
import styles from './Pagination.module.scss' import styles from './Pagination.module.scss'
const Pagination = ({ pageContext }) => { const PageNumber = ({ i, current }) => (
const { previousPagePath, nextPagePath } = pageContext <Link
className={current ? styles.current : styles.number}
to={i === 0 ? '' : `/page/${i + 1}`}
>
{i + 1}
</Link>
)
PageNumber.propTypes = {
i: PropTypes.number.isRequired,
current: PropTypes.bool
}
const PrevNext = ({ prevPagePath, nextPagePath }) => {
const link = prevPagePath || nextPagePath
const rel = prevPagePath ? 'prev' : 'next'
const title = prevPagePath ? 'Newer Posts' : 'Older Posts'
return ( return (
<div className={styles.pagination}> <Link to={link} rel={rel} title={title}>
{nextPagePath ? ( {prevPagePath ? '←' : '→'}
<Link className={styles.paginationLink} to={nextPagePath}> </Link>
&laquo; Older Posts
</Link>
) : null}
{previousPagePath ? (
<Link className={styles.paginationLink} to={previousPagePath}>
Newer Posts &raquo;
</Link>
) : null}
</div>
) )
} }
PrevNext.propTypes = {
prevPagePath: PropTypes.string,
nextPagePath: PropTypes.string
}
const Pagination = ({ pageContext }) => {
const { currentPageNumber, numPages, prevPage, nextPage } = pageContext
const isFirst = currentPageNumber === 1
const isLast = currentPageNumber === numPages
const prevPagePath = currentPageNumber === 2 ? '/' : `/page/${prevPage}`
const nextPagePath = `/page/${nextPage}`
return nextPage > 1 ? (
<div className={styles.pagination}>
<div>{!isFirst && <PrevNext prevPagePath={prevPagePath} />}</div>
<div>
{Array.from({ length: numPages }, (_, i) => (
<PageNumber
key={`pagination-number${i + 1}`}
i={i}
current={currentPageNumber === i + 1}
/>
))}
</div>
<div>{!isLast && <PrevNext nextPagePath={nextPagePath} />}</div>
</div>
) : null
}
Pagination.propTypes = { Pagination.propTypes = {
pageContext: PropTypes.object.isRequired pageContext: PropTypes.object.isRequired
} }

View File

@ -3,18 +3,41 @@
.pagination { .pagination {
display: flex; display: flex;
margin-top: $spacer * 2; margin-top: $spacer * 2;
margin-bottom: $spacer * 2; margin-bottom: $spacer;
} justify-content: center;
.paginationLink { > div {
flex: 1 1 50%; &:first-child {
display: block; margin-right: $spacer;
}
&:last-child { &:last-child {
text-align: right; margin-left: $spacer;
} text-align: right;
}
&:only-child {
text-align: left;
} }
} }
.number {
text-align: center;
width: 2rem;
height: 2rem;
line-height: 1.7;
display: inline-block;
border-radius: 50%;
border: 1px solid transparent;
&:hover,
&:focus {
background: rgba(255, 255, 255, .3);
border-color: darken($brand-grey-dimmed, 5%);
}
}
.current {
composes: number;
cursor: default;
pointer-events: none;
border: 1px solid darken($brand-grey-dimmed, 5%);
color: $brand-grey-light;
}

View File

@ -3,17 +3,14 @@ import PropTypes from 'prop-types'
import { Link } from 'gatsby' import { Link } from 'gatsby'
import Time from 'react-time' import Time from 'react-time'
import slugify from 'slugify' import slugify from 'slugify'
import PostLinkActions from '../atoms/PostLinkActions'
import styles from './PostMeta.module.scss' import styles from './PostMeta.module.scss'
const PostMeta = ({ post, meta }) => { const PostMeta = ({ post, meta }) => {
const { author, updated, tags, type, linkurl } = post.frontmatter const { author, updated, tags, type } = post.frontmatter
const { date, slug } = post.fields const { date } = post.fields
return ( return (
<footer className={styles.entryMeta}> <footer className={styles.entryMeta}>
{type === 'link' && <PostLinkActions slug={slug} linkurl={linkurl} />}
<div className={styles.byline}> <div className={styles.byline}>
<span className={styles.by}>by</span> <span className={styles.by}>by</span>
<a className="fn" rel="author" href={meta.author.uri}> <a className="fn" rel="author" href={meta.author.uri}>
@ -45,7 +42,7 @@ const PostMeta = ({ post, meta }) => {
{tags && ( {tags && (
<div className={styles.tags}> <div className={styles.tags}>
{tags.map(tag => { {tags.map(tag => {
const to = tag === 'goodies' ? '/goodies' : `/tag/${slugify(tag)}/` const to = tag === 'goodies' ? '/goodies' : `/tags/${slugify(tag)}/`
return ( return (
<Link key={tag} className={styles.tag} to={to}> <Link key={tag} className={styles.tag} to={to}>

View File

@ -0,0 +1,120 @@
import React, { Fragment, PureComponent } from 'react'
import PropTypes from 'prop-types'
import { Link, graphql, StaticQuery } from 'gatsby'
import Image from '../atoms/Image'
import styles from './RelatedPosts.module.scss'
const query = graphql`
query {
allMarkdownRemark(sort: { order: DESC, fields: [fields___date] }) {
edges {
node {
id
frontmatter {
title
type
linkurl
tags
image {
childImageSharp {
...ImageFluidThumb
}
}
}
fields {
slug
date(formatString: "MMMM DD, YYYY")
}
}
}
}
}
`
const postsWithDataFilter = (postsArray, key, valuesToFind) => {
const newArray = postsArray.filter(post => {
const frontmatterKey = post.node.frontmatter[key]
if (
frontmatterKey !== null &&
frontmatterKey.some(r => valuesToFind.includes(r))
) {
return post
}
})
return newArray
}
const PostItem = ({ post }) => {
return (
<li>
<Link to={post.node.fields.slug}>
{post.node.frontmatter.image ? (
<Fragment>
<Image
fluid={post.node.frontmatter.image.childImageSharp.fluid}
alt={post.node.frontmatter.title}
/>
<h4 className={styles.postTitle}>{post.node.frontmatter.title}</h4>
</Fragment>
) : (
<div className={styles.empty}>
<h4 className={styles.postTitle}>{post.node.frontmatter.title}</h4>
</div>
)}
</Link>
</li>
)
}
PostItem.propTypes = {
post: PropTypes.object.isRequired
}
class RelatedPosts extends PureComponent {
shufflePosts = () => {
this.forceUpdate()
}
render() {
return (
<StaticQuery
query={query}
render={data => {
const posts = data.allMarkdownRemark.edges
const filteredPosts = postsWithDataFilter(
posts,
'tags',
this.props.tags
)
return (
<aside className={styles.relatedPosts}>
<h1 className={styles.title}>Related Posts</h1>
<ul>
{filteredPosts
.sort(() => 0.5 - Math.random())
.slice(0, 6)
.map(post => (
<PostItem key={post.node.id} post={post} />
))}
</ul>
<button
className={`${styles.button} btn`}
onClick={this.shufflePosts}
>
More Related Posts
</button>
</aside>
)
}}
/>
)
}
}
RelatedPosts.propTypes = {
tags: PropTypes.array.isRequired
}
export default RelatedPosts

View File

@ -0,0 +1,88 @@
@import 'variables';
@import 'mixins';
.empty {
height: 100%;
min-height: 80px;
display: flex;
align-items: center;
padding: $spacer / 4;
h4 {
margin-top: 0;
}
}
.relatedPosts {
margin-top: -($spacer * 2);
margin-bottom: $spacer;
ul {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 0;
margin: 0;
}
li {
display: block;
flex: 0 0 48%;
margin-bottom: $spacer;
@media (min-width: $screen-sm) {
flex-basis: 31%;
}
&::before {
display: none;
}
}
a {
display: block;
> div {
@include media-frame;
}
&:hover,
&:focus {
> div {
border-color: $link-color;
}
h4 {
color: $link-color;
}
}
}
}
.title {
@include heading-band;
font-size: $font-size-h3;
}
.postTitle {
display: inline-block;
margin-top: $spacer / 4;
margin-bottom: 0;
font-size: $font-size-small;
line-height: $line-height-small;
color: $brand-grey-light;
padding-left: .2rem;
padding-right: .2rem;
transition: color .2s ease-out;
@media (min-width: $screen-md) {
font-size: $font-size-base;
}
}
.button {
margin: auto;
display: block;
margin-top: $spacer / 2;
}

View File

@ -202,7 +202,6 @@ picture {
height: auto; height: auto;
margin: 0 auto; margin: 0 auto;
display: block; display: block;
border-radius: $border-radius;
} }
img { img {
@ -323,14 +322,14 @@ cite {
blockquote, blockquote,
blockquote > p { blockquote > p {
font-style: italic; font-style: italic;
color: $brand-grey; color: $brand-grey-light;
} }
// stylelint-enable no-descending-specificity // stylelint-enable no-descending-specificity
blockquote { blockquote {
margin: 0 0 $spacer; margin: 0 0 $spacer;
position: relative; position: relative;
padding-left: $spacer * 1.5; padding-left: $spacer * 1.25;
// quotation marks // quotation marks
&::before { &::before {

View File

@ -8,16 +8,26 @@ import PostTitle from '../components/atoms/PostTitle'
import PostLead from '../components/atoms/PostLead' import PostLead from '../components/atoms/PostLead'
import PostContent from '../components/atoms/PostContent' import PostContent from '../components/atoms/PostContent'
import PostActions from '../components/atoms/PostActions' import PostActions from '../components/atoms/PostActions'
import PostLinkActions from '../components/atoms/PostLinkActions'
import SEO from '../components/atoms/SEO' import SEO from '../components/atoms/SEO'
import Coinhive from '../components/atoms/Coinhive' import Coinhive from '../components/atoms/Coinhive'
import PostMeta from '../components/molecules/PostMeta' import PostMeta from '../components/molecules/PostMeta'
import Exif from '../components/atoms/Exif' import Exif from '../components/atoms/Exif'
import RelatedPosts from '../components/molecules/RelatedPosts'
import styles from './Post.module.scss' import styles from './Post.module.scss'
const Post = ({ data, location }) => { const Post = ({ data, location }) => {
const { markdownRemark: post } = data const { markdownRemark: post } = data
const { contentYaml: meta } = data const { contentYaml: meta } = data
const { title, image, type, linkurl, style, coinhive } = post.frontmatter const {
title,
image,
type,
linkurl,
style,
coinhive,
tags
} = post.frontmatter
const { slug } = post.fields const { slug } = post.fields
return ( return (
@ -37,9 +47,12 @@ const Post = ({ data, location }) => {
)} )}
{image && image.fields && <Exif exif={image.fields.exif} />} {image && image.fields && <Exif exif={image.fields.exif} />}
<PostContent post={post} /> <PostContent post={post} />
{type === 'link' && <PostLinkActions slug={slug} linkurl={linkurl} />}
<PostActions slug={slug} url={meta.url} /> <PostActions slug={slug} url={meta.url} />
<PostMeta post={post} meta={meta} /> <PostMeta post={post} meta={meta} />
</article> </article>
{type === 'post' && <RelatedPosts tags={tags} />}
</Layout> </Layout>
{coinhive && <Coinhive />} {coinhive && <Coinhive />}
</Fragment> </Fragment>

View File

@ -15,7 +15,7 @@ import styles from './Posts.module.scss'
const Posts = ({ data, location, pageContext }) => { const Posts = ({ data, location, pageContext }) => {
const edges = data.allMarkdownRemark.edges const edges = data.allMarkdownRemark.edges
const { tag, previousPagePath, humanPageNumber, numberOfPages } = pageContext const { tag, currentPageNumber, numPages } = pageContext
const PostsList = edges.map(({ node }) => { const PostsList = edges.map(({ node }) => {
const { type, linkurl, title, image } = node.frontmatter const { type, linkurl, title, image } = node.frontmatter
@ -58,11 +58,11 @@ const Posts = ({ data, location, pageContext }) => {
<Layout location={location}> <Layout location={location}>
<SEO /> <SEO />
{location.pathname === '/' && <Featured />} {location.pathname === '/' && <Featured />}
{tag && <h1 className={styles.archiveTitle}>{tag}</h1>} {tag && <h1 className={styles.archiveTitle}>#{tag}</h1>}
{previousPagePath && ( {currentPageNumber > 1 && (
<h1 <h1
className={styles.archiveTitle} className={styles.archiveTitle}
>{`Page ${humanPageNumber} / ${numberOfPages}`}</h1> >{`Page ${currentPageNumber} / ${numPages}`}</h1>
)} )}
{PostsList} {PostsList}
<Pagination pageContext={pageContext} /> <Pagination pageContext={pageContext} />