0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js + TypescriptでミニCMSを作る(4. レビュー一覧画面の作成)

Posted at

そろそろ実装に入ります。

まずはレビュー一覧画面に手を加えていきます。ワイヤーフレームはこんな感じ。(再掲)

レビュー一覧.png

レビュー一覧画面のコンポーネント構成

メニューとパンくずリストはページが増えた後で作ろうと思うので、それ以外(「レビュー一覧」のヘディングテキスト以下)を作っていきます。

コンポーネントの構成は、大まかにこんな感じで考えてみました。

Component-Structure-ReviewList.png

dashboard/review

サーバーコンポーネント。レビュー一覧ページ全体を包括します。
DBデータに依存しないパーツを表示し、ページがリクエストされたときにDBからレビューデータ取得して、Propsとして子コンポーネントに渡します。

components/ReviewCardContainer

クライアントコンポーネント。親からレビューリストを受け取って、個々のレビューデータを子となるReviewCardコンポーネントに渡します。ReviewCardに削除ボタンをつけていて、削除を実行したとき画面のカードリストが即時反映してほしいのでクライアントコンポーネントにしています。
削除ボタンの動作については別の記事でまとめます。

components/ReviewCard

サーバーコンポーネント。ただし、今回は親がクライアントコンポーネントなので、このページはデフォルトでクライアントコンポーネントとして機能します。(コンポーネントに'use client'のキーワードがない場合、呼び出される親と同じ種類のコンポーネントになります。)
親からレビューデータを受け取り、カードとして表示します。
内部に細かいコンポーネントを内包していますが、今は割愛します。

データ取得~レビューカードの表示までの実装

前置きが大変長くなりましたが、ここからNext.jsの実装を進めます。

準備

まず、globals.cssのリセットと、Prismaクライアント、テストデータの準備をします。
画面で使うアイコン素材も、publicフォルダに入れておきます。

globals.css

中身をすべて削除しましょう。
私は今回、ワイヤーフレームを作る際にCSSを一緒に書き出しているので、色やフォントなど全ページ共通で使えるものはこちらのglobals.cssに記載しています。
CSSの話は今回の趣旨からは外れますので、ソースコードは以下に隠しておきますね。

globals.css
globals.css
:root {

  /* Colors: */
  --custom-main-color: #00247A;
  --custom-base-color: #F1F1F4;
  --custom-sub-color: #E7E7F2;
  --custom-sub-dark-color: #7C8AAC;
  --custom-accent-color: #8F8FFF;
  --custom-accent-light-color: #ceceff;
  --custom-gray-color: #4C4C4C;
  --custom-gray-light-color: #C0C0C0;
  --custom-white-color: #FaFaFa;
  --custom-warning-color: #d40125; 

  /* Font/text values */
  --default-font-family-inter: Inter;
  --default-font-style-normal: normal;
  --default-font-weight-600: 600px;
  --default-font-weight-bold: bold;
  --default-font-weight-normal: normal;
  --default-font-size-11: 11px;
  --default-font-size-14: 14px;
  --default-font-size-16: 16px;
  --default-font-size-18: 18px;
  --default-font-size-20: 20px;
  --default-font-size-24: 24px;
  --default-font-size-28: 28px;
  --default-character-spacing-0: 0px;
  --default-line-spacing-14: 14px;
  --default-line-spacing-18: 18px;
  --default-line-spacing-20: 20px;
  --default-line-spacing-24: 24px;
  --default-line-spacing-34: 34px;
  --default-line-spacing-36: 36px;
  --default-decoration-underline: underline;
}
  
html * {
  box-sizing: border-box;
}

body {
    margin: 0;
    background-color: var(--custom-base-color);

}

  /* Character Styles */
h1,
.heading1 {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-bold);
  font-size: var(--default-font-size-28);
  line-height: var(--default-line-spacing-34);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-main-color);
  margin-top: 20px;
  margin-bottom: 16px;
}

h2,
.heading2 {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-bold);
  font-size: var(--default-font-size-20);
  line-height: var(--default-line-spacing-24);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-main-color);
  margin-top: 10px;
  margin-bottom: 8px;
}

p {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-14);
  line-height: var(--default-line-spacing-18);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-main-color);
  margin-top: 8px;
  margin-bottom: 8px;
}

h3,
.heading3 {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-18);
  line-height: var(--default-line-spacing-20);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-main-color);
}
.menu-font {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-600);
  font-size: var(--default-font-size-24);
  line-height: var(--default-line-spacing-36);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-gray-color);
}
.memo-link {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-11);
  line-height: var(--default-line-spacing-14);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-sub-dark-color);
  text-decoration: var(--default-decoration-underline);
}
.memo {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-11);
  line-height: var(--default-line-spacing-14);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-sub-dark-color);
}
.placeholder {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-16);
  line-height: var(--default-line-spacing-18);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-gray-light-color);
}
.tag {
  font-family: var(--default-font-family-inter);
  font-style: var(--default-font-style-normal);
  font-weight: var(--default-font-weight-normal);
  font-size: var(--default-font-size-11);
  line-height: var(--default-line-spacing-14);
  letter-spacing: var(--default-character-spacing-0);
  color: var(--custom-white-color);
}
.errorMessage {
  color: var(--custom-warning-color);
  font-weight: var(--default-font-weight-bold);
}

Prismaクライアント

Prismaを使いたいときはPrismaクライアントを使いますが、各所でインスタンスを作るのは効率が悪いので、共通して使える単一インスタンスをつくります。

lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma || new PrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

これで各ページにprismaをimportすることで利用することができます。

さらに、Prismaクライアントを利用して、レビューデータの型を設定しておくことで、Props受け渡しの際に型を定義するのが楽になります。
レビュー一覧画面では、createdAt,updatedAt以外のReviewテーブルの各カラムと、各レコードにリレーションを持っているProductテーブルのid,nameがあればOKなので、それを設定します。
共通利用ができるよう、定義する型はapp/types/index.d.tsにまとめます。

app/types/index.d.ts
import { Review } from '@prisma/client'

export type ReviewWithProductId = Omit < Review,
"createdAt" | "updatedAt" > & {
    products: {
        id: number,
        name: string | null,
    }[] | []
}

テストデータ

Series, Productテーブルに適当なレコードをいくつか追加します。
それと、Reviewテーブルに、以下の3パターン、ダミーデータを作成しておきます。

  • 下書き用(isDraft = true)
  • トップ掲載記事(isDraft = false && displayTop = true)
  • その他の公開記事(isDraft = false && displayTop = false)

それぞれのレビューレコードに、製品情報を紐づけておきましょう。

npx prisma studioでPrisma Studioを起動すると、簡単にデータをセットできます。

dashboard/review

まずはページを構成する親コンポーネントを作ります。

まず、ディレクトリを切ります。管理画面は、dashboard/の配下にあってほしいので、appフォルダの下にdashboard/、さらにその下にreview/のディレクトリを作ります。
dashboard/review/のディレクトリ配下に、page.tsxreviews.module.cssを作ります。CSSをモジュールに切り分けることで、コンポーネントを使いまわした際のCSS衝突、デザイン崩れを防ぐことができます。

dashboard/review/page.tsx
import Link from "next/link";
import styles from "./reviews.module.css";
import { prisma } from "@/lib/prisma";
import { ReviewWithProductId } from "@/app/types";
import ReviewCardContainer from "@/app/components/ReviewCardContainer/ReviewCardContainer";

export default async function ReviewList() {
    const reviews : ReviewWithProductId[] = await prisma
    .review
    .findMany({
        omit: {
            createdAt :true,
            updatedAt :true,
        },
        include: {
            products : {
                select: {
                    id: true,
                    name: true
                }
            }
        }
    });
    
    const drafts = reviews.filter(review => review.isDraft)
    const topArticles = reviews.filter(review => review.displayTop && (!review.isDraft))
    const otherArticles = reviews.filter(review => (!review.displayTop) && (!review.isDraft))

    return (
        <div className={styles.container}>
            <div className={styles.headContainer}>
                <h1>レビュー一覧</h1>
                <Link href="/dashboard/review/new" className={styles.textButton}>
                    新規投稿
                </Link>
            </div>
            
            <ReviewCardContainer title="下書き" reviews={drafts}/>
            <ReviewCardContainer title="トップページ掲載中" reviews={topArticles}/>
            <ReviewCardContainer title="その他" reviews={otherArticles}/>

        </div>
    )
}
CSS
dashboard/review/reviews.module.css
.container{
  width: 100%;
  max-width: 1072px;
  margin: 10px auto;
}

.headContainer {
  display: flex;
  align-content: space-between;
  justify-content: space-between;
}

.textButton{
  text-decoration: none;
  box-sizing: content-box;
  background-color: var(--custom-white-color);
  margin: auto 0px;
  display: block;
  padding: 8px 16px;
  border-radius: 3px;
  border: solid  var(--custom-main-color) 1px;
  color: var(--custom-main-color);
  font-weight: bold;
}

.textButton:hover {
    opacity: 0.7;
}

取得したデータをフラグによって3種類(drafts / topArticles / otherArticles)に分類分けします。
この分けたデータをそれぞれ子コンポーネントReviewCardContainerにPropsとして渡してあげる構造です。

データはPrismaを使ってReviewテーブルの情報を取得しています。
omit,include,selectキーワードを使って、必要なデータのみ取得することで、応答のサイズを小さくし、クエリ速度を向上させることができます。
クエリ部分はlib/prisma.tsで設定した型に合わせています。今後DBスキーマを拡張した際に、設定した型以外の余計なデータが入ってくるリスクも減らすことができるので、積極的に使っていきましょう。

omitAPI

omitを使ってフィールドをローカルで除外する機能は、プレビュー機能となりますので、schema.prismaomitAPIの利用を明示する必要があります。

shema.prisma
generator client {
  provider = "prisma-client-js"
+  previewFeatures = ["omitApi"]
}

components/ReviewCardContainer

ページ内に乗せる個々の部品は、app/components/フォルダに作っていきます。

components/ReviewCardContainer/ReviewCardContainer.tsx
'use client'

import { ReviewWithProductId } from "@/app/types";
import styles from "./CardContainer.module.css";
import ReviewCard from "@/app/components/ReviewCard/ReviewCard";

type Props = {
  title: string;
  reviews: ReviewWithProductId[];
}

export default function ReviewCardContainer(props: Props) {

  const reviews = props.reviews;
  
  return ( 

    <div>
      <hr />
      <div className={styles.titleContainer}>
        <h2>{props.title}</h2>
        <p>{reviews.length}</p>
      </div>
      {reviews.length == 0 
      ?  (<p className={styles.noPost}>投稿はありません。</p>) 
      :  reviews.map((review, index) =>(
          <ReviewCard key={index} review={review} />
        ))}
    </div>
  )
}
CSS
components/ReviewCardContainer/CardContainer.module.css
.titleContainer{
  display: flex;
}

.titleContainer * {
  margin: auto 10px;
}

.titleContainer p {
  background-color: var(--custom-accent-light-color);
  padding: 2px 6px;
  border-radius: 50px;
  text-align: center;
  font-weight: bold;
  color:  var(--custom-main-color);
  opacity: 0.8;
}

.noPost {
  margin: 10px 20px;
  padding: 10px;
}

親から受け取ったレビューデータの配列を、map()で一要素ずつReviewCardコンポーネントにPropsにして渡します。
受け取った配列に何もなかった場合は「投稿はありません」というメッセージを表示したいので、三項演算子で条件分岐させます。
Next.js(というかReact)のreturn()内部ではif文が書けないので、条件分岐させたい場合はこの方法を取ります。

components/ReviewCard

components/ReviewCard/ReviewCard.tsx
import Image from "next/image";
import styles from "./reviewCard.module.css";
import Link from "next/link";
import { ReviewWithProductId } from "@/app/types";

interface Props {
  review: ReviewWithProductId,
}

export default function ReviewCard({review} : Props) {

    return (
        <div
            className={`${styles.reviewCard} ${review.isDraft
            ? styles.draft
            : ""}`}>
            <div className={styles.innerBox}>
                <div className={styles.buttonContainer}>
                    <Image src="/img/icon/awesome-bars.svg" width={20} height={20} alt="並べ替え"/>
                </div>

                {/* THUMBNAIL */}
                <div className={styles.imageContainer}>
                    <Image
                        src={review.imgUrl || "/img/placehold_500_300.png"}
                        alt="サムネイル画像"
                        fill
                        sizes="150px"
                        quality={50}
                        className={styles.thumbnail}/>
                </div>

                <div className={styles.textContainer}>

                    {/* TITLE */}
                    <div>
                        {review.url
                            ? (
                                <Link href={review.url} className="heading2" target="_blank">{review.title}</Link>
                            )
                            : (
                                <h2>{review.title}</h2>
                            )
                        }
                    </div>


                    {/* DATE */}
                    <div>
                        {review.articleDate
                            ? (
                                <p className="memo">
                                    {review.articleDate?.getFullYear()}{review.articleDate
                                            ? review.articleDate?.getMonth() + 1
                                            : ""}{review.articleDate?.getDate()}</p>
                            )
                            : ""}

                    </div>

                    {/* DETAIL */}
                    <div className={styles.detail}>
                        <p>{review.detail}</p>
                    </div>

                    {/* TAG */}
                    <div className={styles.tagContainer}>

                        <p className="tag">
                            {review
                                .products
                                .map((product, index) => (
                                    <mark key={index} className={styles.productTag}>{product.name}</mark>
                                ))}
                        </p>

                    </div>
                </div>
                <div className={styles.buttonContainer}>
                    <Link href={`/dashboard/review/${review.id}`} className={styles.buttonLink}>
                        <Image src="/img/icon/awesome-edit.svg" width={20} height={20} alt="編集"/>
                    </Link>

                    <button className={styles.deleteButton}>
                        <Image src="/img/icon/material-delete.svg" width={20} height={20} alt="削除"/>
                    </button>

                </div>
            </div>

        </div>
    )
}
CSS
components/ReviewCard/reviewCard.module.css
.reviewCard.draft {
  padding: 10px;
  background: var(--custom-sub-color) 0% 0% no-repeat padding-box;
  box-shadow: 0px 0px 10px #00000029;
  border-radius: 3px;
}

.reviewCard {
  padding: 10px;
  background: var(--custom-white-color) 0% 0% no-repeat padding-box;
  box-shadow: 0px 0px 10px #00000029;
  border-radius: 3px;
  margin: 10px 0;
}

.reviewCard h2 {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 1;
  overflow: hidden;
}

.innerBox {
  display: grid;
  width: 100%;
  grid-template-columns: 40px 1fr 5fr 80px;
}

.textContainer{
 justify-self: stretch;
 padding: 0 10px;
 width: 100%;
}

.detail p {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}

.tagContainer{
  width: 100%;
}

.productTag {
  background-color: var(--custom-accent-color);
  color: #fff;
  border-radius: 50px;
  padding: 4px 14px;
  margin: 4px;
  display: inline-block;
}

.buttonContainer{
  margin: auto;
}

.buttonLink {
  margin: 0;
  padding: 5px;
  margin: 5px;
}

.buttonLink:hover {
  opacity: 0.7;
}

.imageContainer {
  position: relative;
  margin: 4px;
  min-height: 110px;
}


.thumbnail{
  object-fit: contain;
}

.deleteButton {
  border: 0;
  background-color: transparent;
  cursor: pointer;
}

.deleteButton:hover {
  opacity: 0.7;
}

こんな感じにできてきました。

レビュー一覧_基礎構造完了.png

まだ作っていない機能や、カンプからデザインを変更した部分もありますが、概ね形ができてきましたね!

次回

大枠のページができてきたので、複雑な機能の作り込みを進めていきます。components/ReviewCardもごたついてるので、ちょっと整理します。

  • 時間の調整(UTCと日本時間との調整)
  • レビュー削除ボタン
  • サムネイル表示部分(Youtube分岐、Cloudinaryの活用)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?