📸

Next.js14とmicroCMSで写真ギャラリーサイトを作った

2024/12/15に公開

今回作ったもの

最近一眼レフに本格的にハマり、なけなしのお金を叩いてsonyのカメラに乗り換えました。
撮ってみるととても綺麗!美しい!高画質!これを皆んなに共有したい!
でもインスタにアップすると画質落ちちゃう(なんか抜け道があるそうな。。。??)
ほな、自分で共有できるサイト作ればいいのでは??ということで写真ギャラリーサイトを作りました。

microCMS上で画像のアップロードを行い、それをnextjsで取ってきて表示するサイトとなっております。

本記事では、私がnext初心者ということもあり、詰まって調べたところなどをアコーディオンを使ってまとめつつ作成の方法をまとめていきます。

想定読者

  • next初めてで何作ろうかしらと悩んでいるあなた
  • microCMS触ってみたいというあなた
  • 写真ギャラリーサイト作りたいというあなた!

この記事では書かないこと

  • yamada-uiの詳細な設定(別記事で書こうかなと思っております)
  • vercelへのデプロイ方法(わかりやすい記事がたくさん転がっているのでぜひそちらを参考に)

使用技術

  • Nextjs14
  • microCMS
  • yamada-ui
  • vercel

microCMSとは

microCMSとはヘッドレスcmsです。
みなさんご存知のwordpressなどのcmsと異なる点としてヘッドレスであるため、コンテンツの管理等は通常のCMS同様に行いますが、フロントエンドの描画等の機能は持ちません。
そのため、microCMS上で作成したエンドポイントを叩いてデータを取得するという形をとります。

(当初はgoogle photo library api使おうとしていましたが、api側の仕様変更等に苦戦、かつmicrocms上で写真のタグ付け等もできて管理が楽そうだったので選択しました)

microCMS周りの設定

microCMS側の設定

microCMSの設定は簡単です。apiの作成は公式ドキュメントの方に詳しく載っているかと思うので省略します。
今回私は写真の表示に加えてカテゴリごとの表示や撮った日順で表示がしたかったので、下記のようにスキーマを設定しました。写真に関しては複数画像と単一画像の二つがあったのですが今回は単一画像のにしました。
複数画像は画像のみのリストが返されるので今回のように写真ごとにタグ付けやコメント等をつけたい場合は単一画像が良さそうです。

あとは適当に写真を登録しておきます。これでmicroCMS側の設定は終了です。

nextjs側の設定

次にnextjsからmicroCMSを叩く設定を行なっていきます。APIキーとservice-domainを使うのでmicroCMS上で確認しておいてください。

今回はmicroCMSがSDKを準備してくれているのでmicrocms-js-sdkをありがたく使います。
まず、jsonをいい感じに受け取れるようにtypes/microcmstype.tsを下記のように作成しました。


export type MicroCMSPhoto = {
    id: string,
    createdAt: string,
    updatedAt: string,
    publishedAt: string,
    revisedAt: string,
    image: {
      url: string;
      height: number;
      width: number;
    },
    takenAt: string,
    category: string[],
    comment: string,
};

utils/microcms.tsを作成し以下のように記述します。

import { createClient } from "microcms-js-sdk";
import { MicroCMSPhoto } from '@/types/microcmstype';


if (!process.env.MICROCMS_API_KEY) {
  throw new Error("MICROCMS_API_KEY is required");
}

// API取得用のクライアントを作成
export const client = createClient({
  serviceDomain: '自分のサービスドメイン名',
  apiKey: process.env.MICROCMS_API_KEY,
});

export async function getImages(): Promise<MicroCMSPhoto[]> {
  const data = await client.get({
    endpoint: 'photo',
    queries: {
      limit: 20,
    },
  });
  return data.contents;
}

なお、APIキーは.env.localに記述しておきます。

NEXT_PUBLICプレフィックスについて

nextjsで環境変数を設定する場合、NEXT_PUBLICを付けるか否かという選択に迫られます。
つけた場合はクライアントコンポーネントから参照することができます。すなわち、クライアントから検証ツールを使うことによって見ることができます。
そのため、APIキーなどの場合は、つけない方が良いです。
もちろん今回はつけずに設定しました。

これで上記メソッドを欲しいところから呼び出せば画像を取ってこれます。
私はトップページで新しい順に数枚選んでカルーセルで表示させ、galleryページでは一覧できるようにしています。
参考までにgallery周りのコードを載せておきます(長いので折りたたんでおきます)。GalleryコンポーネントはServer-Side componentとし、Galleryコンポーネントで画像をフェッチしたものをGridレイアウトで表示させるCategorySelectorコンポーネント(Client component)にpropsとして渡し描画しています。

program
gallery/page.tsx
import React from 'react';
import { client } from '@/utils/microcms';
import { MicroCMSPhoto } from '@/types/microcmstype';
import CategorySelector from './components/categorySelector';

async function getImages(): Promise<MicroCMSPhoto[]> {
  const data = await client.get({
    endpoint: 'photo',
    queries: {
      limit: 20,
    },
  });
  return data.contents;
}

export default async function Gallery() {
  const data = await getImages();

  return (
    <div>
      <CategorySelector props={data}/>
    </div>
  );
}

categorySelector.tsx
'use client';
import React, { useState, useEffect } from 'react';
import { Select, SelectItem, Flex, Grid, GridItem, CardBody, Card, Image, Button, CardFooter } from "@yamada-ui/react";
import { MicroCMSPhoto } from '@/types/microcmstype';
import DetailModal from '@/app/components/detailModal';
import { Zen_Kurenaido, Sawarabi_Gothic } from "next/font/google";
import { CalendarArrowDownIcon, CalendarArrowUpIcon } from "@yamada-ui/lucide"

const SawarabiGothicFont = Sawarabi_Gothic({
  weight: "400",
  subsets: ["latin"],
});

const ZenKurenaidoFont = Zen_Kurenaido({
  weight: "400",
  subsets: ["latin"],
});

type SelectorProps = {
  props: MicroCMSPhoto[];
}

const CategorySelector: React.FC<SelectorProps> = ({props}) => {
  const [selectCategory, setSelectCategory] = useState<string>('');
  const [renderData, setRenderData] = useState<MicroCMSPhoto[]>(props);
  const [categoryItems, setCategoryItems] = useState<SelectItem[]>([]);
  const [isOpen, setIsOpen] = useState<boolean>(false);
  const [modalProps, setModalProps] = useState<MicroCMSPhoto>({
    id: '',
    createdAt: '',
    updatedAt: '',
    publishedAt: '',
    revisedAt: '',
    image: {
      url: '',
      height: 0,
      width: 0,
    },
    takenAt: '',
    category: [],
    comment: '',
  });
  const [ascFlag, setAscFlag] = useState<boolean>(false);


  useEffect(()=> {
    const categories = new Set<string>();
    props.forEach((photo) => {
      categories.add(photo.category[0]);
    });

    setCategoryItems(
      Array.from(categories).map((category) =>({
        value: category,
        label: category,
      }))
    );
  }, [props]);

  useEffect(() => {
    if (selectCategory === '') {
      setRenderData(props);
    } else {
      setRenderData(props.filter((photo) => photo.category[0] === selectCategory));
    }
  }, [selectCategory, props]);

  useEffect(() => {
    const sortedData = [...renderData].sort((a, b) => {
      const dateA = new Date(a.takenAt).getTime();
      const dateB = new Date(b.takenAt).getTime();
      return ascFlag ? dateA - dateB : dateB - dateA;
    });

    setRenderData(sortedData);
  }, [ascFlag]);

  const modalHandler = (props: MicroCMSPhoto) => {
    setIsOpen(true);
    setModalProps(props);
  };

  return (
    <>
      <Flex justify='right' gap='2' mb='2'>
        <Button onClick={() => setAscFlag(!ascFlag)} className={ SawarabiGothicFont.className } bg='#fff'>
          { ascFlag ? <CalendarArrowUpIcon fontSize='3xl'/> : <CalendarArrowDownIcon fontSize='3xl' /> }
        </Button>
        <Select
          placeholder="All"
          w="10%"
          border="2px solid #ccc"
          borderRadius="10px"
          items={categoryItems}
          onChange={(value) => setSelectCategory(value)}
          className={ SawarabiGothicFont.className }
          borderColor='#333'
        />
      </Flex>
      <Grid templateColumns="repeat(3, 1fr)" gap="2%" mb='15%'>
        {renderData.map((photo, index) => (
          <GridItem key={index}>
              <Card backgroundColor='white'>
                <CardBody>
                    <Image src={photo.image.url} alt="photo" onClick={() => modalHandler(photo)} objectFit="cover"/>
                </CardBody>
                <CardFooter justifyContent="center" className={ZenKurenaidoFont.className}>
                  { photo.comment}
                </CardFooter>
              </Card>
          </GridItem>
        ))}
      </Grid>

      {isOpen && <DetailModal imageInfo={modalProps} onClose={() => setIsOpen(false)}/>}
    </>
  );
};

export default CategorySelector;

ただ表示させるだけならGalleryコンポーネントだけでもいいですが、ユーザーの入力に従ってカテゴリをフィルタしたり、日付昇降順に並べたりとuseStateを使いたかったのでコンポーネントを切り分けました。クライアントとサーバーをどう切り分けるかについて、なぜ切り分けるかについては色々調べたので下にメモしておきます。

SSRとCSRについて

NEXT初心者の私にはこの違いも正直最初はわかりませんでした。
SSRはサーバサイドレンダリングといって、サーバサイド側でjsを実行してhtmlを生成した上でそれをクライアントに渡します。そのため、クライアントの負荷が減りますし、SEOでも優里だそうです。

一方でCSRはクライアントサイドレンダリングといい、クライアント側でjsを実行しhtmlを生成します。そのため、ページ遷移が高速ですし、useStateやuseEffectなどのhookも使うことができます。

今回、この違いも私を悩ませた点で前述の通り、NEXT_PUBLICのプレフィクスをつけた環境変数はサーバコンポーネントでしか扱えません。そのためカテゴリ機能や日付昇降順操作などuseStateを使いたい画面でいかにして画像を描画するか困りました。

しかし解決策は非常に単純で、基本ページはサーバコンポーネントにしてサーバサイドでapiをフェッチしてそのデータをpropsとしてクライアントコンポーネントに渡してやれば良いのです。
クライアントコンポーネントとサーバーコンポーネントの境界については以下の記事が参考になったのでぜひ参照してみてください。
https://zenn.dev/luvmini511/articles/ec0e874a2cc1f1

今後の展望

色々やりたいことはあるのですが、一番はレスポンシブ対応をさせます。
これはyamadauiについてまとめながらさせていこうかなと思います。
他にも脳死で書いたコードをリファクタリングしたり、写真を地図表示させたりしたいですね、また記事を書こうと思います。

まだまだnextを始めたばかりなので何かあればぜひコメントで教えていただけると嬉しいです。

参考文献

https://zenn.dev/gohshi0514/articles/0ad573d5ba1722
https://blog.microcms.io/microcms-next-jamstack-blog/
https://zenn.dev/kibe/articles/7c09742400aa66
https://zenn.dev/luvmini511/articles/ec0e874a2cc1f1

Discussion