そろそろ実装に入ります。
まずはレビュー一覧画面に手を加えていきます。ワイヤーフレームはこんな感じ。(再掲)
レビュー一覧画面のコンポーネント構成
メニューとパンくずリストはページが増えた後で作ろうと思うので、それ以外(「レビュー一覧」のヘディングテキスト以下)を作っていきます。
コンポーネントの構成は、大まかにこんな感じで考えてみました。
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
: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クライアントを使いますが、各所でインスタンスを作るのは効率が悪いので、共通して使える単一インスタンスをつくります。
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
にまとめます。
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.tsx
とreviews.module.css
を作ります。CSSをモジュールに切り分けることで、コンポーネントを使いまわした際のCSS衝突、デザイン崩れを防ぐことができます。
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
.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.prisma
でomitAPI
の利用を明示する必要があります。
generator client {
provider = "prisma-client-js"
+ previewFeatures = ["omitApi"]
}
components/ReviewCardContainer
ページ内に乗せる個々の部品は、app/components/
フォルダに作っていきます。
'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
.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
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
.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;
}
こんな感じにできてきました。
まだ作っていない機能や、カンプからデザインを変更した部分もありますが、概ね形ができてきましたね!
次回
大枠のページができてきたので、複雑な機能の作り込みを進めていきます。components/ReviewCard
もごたついてるので、ちょっと整理します。
- 時間の調整(UTCと日本時間との調整)
- レビュー削除ボタン
- サムネイル表示部分(Youtube分岐、Cloudinaryの活用)