Stackbitブログ(Next.js)の記事ページに関連記事を表示させる。

Stackbitブログ(Next.js)の記事ページに関連記事を表示させる。

Stackbit ブログ(Next.js)の記事ページに関連記事を表示させました!

こんにちは、junwatanabe72 です。
ブログの記事ページには関連記事を表示させたいですよね。
Stackbit ブログのデフォルトにはない機能なので、今回も自分で実装してみました。

「関連記事表示」の要件

  • 記事ページの最下段に関連記事を表示させる。
  • 記事ページのカテゴリーと同カテゴリーの最新記事が関連記事として表示される。
  • 関連記事は最新の記事から2つのみ表示される。

「関連記事表示」の実装

1.ライブラリのインストール

今回は特にありません。

2.ファイルの編集及び新規作成

編集するファイル:「src/components/BlogContent.js」、「src/layouts/post.js」
新規作成ファイル:「PostList.js」、「SearchBar.js」、「RelationContent.js」

2.1 BlogContent.js の編集

編集というより分解します。具体的には、検索バーと記事一覧を別のコンポーネントに分けます。 分離させた記事一覧のコンポーネントは関連記事を表示させる際に流用します。

これを

こうします

以下は、元々の BlogContent.js です。

BlogContent.js;
import React, { useState } from 'react';
import _ from 'lodash';
import SearchIcon from '@material-ui/icons/Search';
import TextField from '@material-ui/core/TextField';
import moment from 'moment-strftime';
import MenuItem from '@material-ui/core/MenuItem';
import { Link, getPageUrl, withPrefix } from '../utils';

const BlogContent = ({ allArticles }) => {
    const [articles, setArticles] = useState(allArticles);
    const [keyward, setKeyward] = useState('');

    const renderPost = (post, index) => {
        const title = _.get(post, 'title');
        const thumbImage = _.get(post, 'thumb_img_path');
        const thumbImageAlt = _.get(post, 'thumb_img_alt', '');
        const excerpt = _.get(post, 'excerpt');
        const date = _.get(post, 'date');
        const dateTimeAttr = moment(date).strftime('%Y-%m-%d %H:%M');
        const formattedDate = moment(date).strftime('%B %d, %Y');
        const postUrl = getPageUrl(post, { withPrefix: true });

        return (
            <article key={index} className="post post-card">
                <div className="post-inside">
                    {thumbImage && (
                        <Link className="post-thumbnail" href={postUrl}>
                            <img src={withPrefix(thumbImage)} alt={thumbImageAlt} />
                        </Link>
                    )}
                    <header className="post-header">
                        <h2 className="post-title">
                            <Link href={postUrl} rel="bookmark">
                                {title}
                            </Link>
                        </h2>
                    </header>
                    {excerpt && (
                        <div className="post-content">
                            <p>{excerpt}</p>
                        </div>
                    )}
                    <footer className="post-meta">
                        <time className="published" dateTime={dateTimeAttr}>
                            {formattedDate}
                        </time>
                    </footer>
                </div>
            </article>
        );
    };
    const changeArticles = (keyward) => {
        const tmp = allArticles.filter((v) => {
            const tags = v.subtitle.split(' ');
            return tags.includes(keyward);
        });
        setArticles(tmp);
        return;
    };
    const handleChange = (event) => {
        setKeyward(event.target.value);
        changeArticles(event.target.value);
        return;
    };
    const options = () => {
        const list = [];
        Object.values(allArticles).forEach((v) => {
            const tmp = v.subtitle.split(' ');
            tmp.map((v) => list.push(v));
        });
        return [...new Set(list)];
    };
    return (
        <>
            <div className="search">
                <div>キーワード検索</div>
                <SearchIcon />
                <TextField className="select-field" id="select-keyward" select onChange={handleChange} value={keyward}>
                    {[...options()].map((option) => (
                        <MenuItem key={option} value={option}>
                            {option}
                        </MenuItem>
                    ))}
                </TextField>
            </div>
            <div className="post-feed-inside">
                {articles.map((post, index) => {
                    return renderPost(post, index);
                })}
            </div>
        </>
    );
};
export default BlogContent;

修正後の BlogContent.js です。

BlogContent.js;
import React, { useState } from 'react';
import _ from 'lodash';
import SearchBar from './atoms/SearchBar';
import PostList from './PostList';

const BlogContent = ({ allArticles }) => {
    const [articles, setArticles] = useState(allArticles);
    const [keyword, setKeyword] = useState('');

    const changeArticles = (keyword) => {
        const tmp = allArticles.filter((v) => {
            return v.subtitle.includes(keyword);
        });
        setArticles(tmp);
        return;
    };
    const handleChange = (event) => {
        setKeyword(event.target.value);
        changeArticles(event.target.value);
        return;
    };
    const options = () => {
        const list = [];
        Object.values(allArticles).forEach((v) => {
            v.subtitle.map((v) => list.push(v));
        });
        return [...new Set(list)];
    };
    return (
        <>
            <SearchBar onChange={handleChange} keyword={keyword} options={options} />
            <PostList articles={articles} />
        </>
    );
};
export default BlogContent;

SearchBar.js です。

SearchBar.js;
import React from 'react';
import _ from 'lodash';
import SearchIcon from '@material-ui/icons/Search';
import TextField from '@material-ui/core/TextField';
import MenuItem from '@material-ui/core/MenuItem';

const SearchBar = ({ onChange, keyward, options }) => {
    return (
        <div className="search">
            <div>キーワード検索</div>
            <SearchIcon />
            <TextField className="select-field" id="select-keyward" select onChange={onChange} value={keyward}>
                {[...options()].map((option) => (
                    <MenuItem key={option} value={option}>
                        {option}
                    </MenuItem>
                ))}
            </TextField>
        </div>
    );
};
export default SearchBar;

PostList.js です。

PostList.js;
import React from 'react';
import _ from 'lodash';
import moment from 'moment-strftime';
import { Link, getPageUrl, withPrefix } from '../utils';

const PostList = ({ articles }) => {
    const renderPost = (post, index) => {
        const title = _.get(post, 'title');
        const thumbImage = _.get(post, 'thumb_img_path');
        const thumbImageAlt = _.get(post, 'thumb_img_alt', '');
        const excerpt = _.get(post, 'excerpt');
        const date = _.get(post, 'date');
        const dateTimeAttr = moment(date).strftime('%Y-%m-%d %H:%M');
        const formattedDate = moment(date).strftime('%B %d, %Y');
        const postUrl = getPageUrl(post, { withPrefix: true });

        return (
            <article key={index} className="post post-card">
                <div className="post-inside">
                    {thumbImage && (
                        <Link className="post-thumbnail" href={postUrl}>
                            <img src={withPrefix(thumbImage)} alt={thumbImageAlt} />
                        </Link>
                    )}
                    <header className="post-header">
                        <h2 className="post-title">
                            <Link href={postUrl} rel="bookmark">
                                {title}
                            </Link>
                        </h2>
                    </header>
                    {excerpt && (
                        <div className="post-content">
                            <p>{excerpt}</p>
                        </div>
                    )}
                    <footer className="post-meta">
                        <time className="published" dateTime={dateTimeAttr}>
                            {formattedDate}
                        </time>
                    </footer>
                </div>
            </article>
        );
    };
    return (
        <div className="post-feed-inside">
            {articles.map((post, index) => {
                return renderPost(post, index);
            })}
        </div>
    );
};
export default PostList;
2.2 post.js の編集

関連記事を表示させる RelationContent コンポーネントを読み込みます。

なお、props として渡している relationArticles は自身の記事を除いた記事一覧です。 本来であれば、keyword ごとに関連記事を表示させるべきですが、次回の実装とします。

post.js;
import React from 'react';
import _ from 'lodash';
import moment from 'moment-strftime';
import { Layout } from '../components/index';
import RelationContent from '../components/atoms/RelationContent';
import ShareButtons from '../components/atoms/ShareButtons';
import { htmlToReact, withPrefix, markdownify } from '../utils';

export default class Post extends React.Component {
    render() {
        const data = _.get(this.props, 'data');
        const config = _.get(data, 'config');
        const page = _.get(this.props, 'page');
        const title = _.get(page, 'title');
        const subtitle = _.get(page, 'subtitle');
        const image = _.get(page, 'content_img_path');
        const imageAlt = _.get(page, 'content_img_alt', '');
        const date = _.get(page, 'date');
        const dateTimeAttr = moment(date).strftime('%Y-%m-%d %H:%M');
        const formattedDate = moment(date).strftime('%A, %B %e, %Y');
        const markdownContent = _.get(page, 'markdown_content');
        const posts = _.orderBy(_.get(this.props, 'posts', []), 'date', 'desc');
        const { __metadata } = page;
        const URL = `https://junwatanabe72.com${__metadata.urlPath}`;
        const relationArticles = posts.filter((post) => post.title !== title);
        return (
            <Layout page={page} config={config}>
                <article className="post post-full">
                    <header className="post-header inner-sm">
                        <h1 className="post-title underline">{title}</h1>
                    </header>
                    <div className="inner-sm">
                        <ShareButtons url={URL} text={title} />
                    </div>
                    {image && (
                        <div className="post-image">
                            <img src={withPrefix(image)} alt={imageAlt} />
                        </div>
                    )}
                    {markdownContent && <div className="post-content inner-sm">{markdownify(markdownContent)}</div>}
                    <footer className="post-meta inner-sm">
                        <time className="published" dateTime={dateTimeAttr}>
                            {formattedDate}
                        </time>
                    </footer>
                    <RelationContent articles={relationArticles} keyword={subtitle[0]} />
                </article>
            </Layout>
        );
    }
}
2.3 RelationContent.js の新規作成

キーワードでフィルタリングした filteredArticles を PostList に props として渡しています。 PostList をコンポーネント化したため、使い回すことができるようになりました。

RelationContent.js;
import React from 'react';
import _ from 'lodash';
import FavoriteIcon from '@material-ui/icons/Favorite';
import PostList from '../PostList';

const title = '関連記事';

const RelationContent = ({ articles, keyword }) => {
    const filteredArticles = articles.filter((article, num) => {
        const keys = article.subtitle;
        return keys.includes(keyword) && num < 2;
    });

    return (
        <div className="inner-sm">
            <div className="common-flex">
                <FavoriteIcon fontSize="large" color="secondary" />
                <h3 className="ignore">{title}</h3>
            </div>
            {filteredArticles.length !== 0 && (
                <>
                    <h5>{`${keyword}の${title}`}</h5>
                    <PostList articles={filteredArticles} />
                </>
            )}
        </div>
    );
};

export default RelationContent;
3.実装の要点
  • BlogContent.js を分解し、「SearchBar.js」,「PostList.js」を新規作成する。
  • 関連記事を表示させる「RelationContent.js」を作成し、「PostList.js」を流用する。
  • 「post.js」内で「RelationContent.js」を読み込む。
  • keyword ごとに関連記事を分ける実装は次回以降。

「関連記事表示」の完成品

まとめ

コンポーネントの粒度を細かくして、いかに使い回すかが、react の面白いところですよね。 コードを書くだけじゃなくて、設計をしっかり考えないといけませんね!

関連記事

PROGRAMの関連記事