[Next.js]簡単なアプリ作成やってみた3

## はじめに
Next.jsにチャレンジしていく過程をメモ/備忘録として記録していきます。
同じようにこれから始める方の参考となればと思います。

## やりたいこと
Next.jsで学習用としてTODOアプリを作ってみます。
バックエンドはDjangoにて作成しAPIでデータ取得としますが、その部分は別の記事にしていきたいと思います。

今回は下記の続きをpart3としてやっていきます。

https://qiita.com/mkthrkw/items/a42e9b1d597950d24ee7

https://qiita.com/mkthrkw/items/d47427f9221044201dba

ログイン機能は出来たので、いよいよ中身を作っていきます。

## 環境
下記のDocker開発環境にて行います。

https://qiita.com/mkthrkw/items/30115c9ac54c2204faef


## プロジェクト一覧ページ
プロジェクトのデータを取得して、プロジェクト一覧ページとサイドバーに表示していきます。


### util/fetch/get.ts

まずは、プロジェクト一覧を取得するためにGetメソッドのfetch処理を作成します。
ログインのGet処理も一緒にしたいので、hasTokenを設けます。
やってみた２で作成したPOST同様に、エラーメッセージも好きなものを使えるようにしておきます。

```typescript:util/fetch/get.ts
import 'server-only';
import { getToken } from '../cookies/token';

type Props = {
  url: string,
  hasToken?: boolean,
  customErrorMessage?: { [key: number]: string },
};

type FetchProps = {
  method: string,
  headers: {
    'Content-Type': string,
    'Authorization'?: string,
  },
};

export default async function fetchGet({ url, hasToken = false, customErrorMessage = {} }: Props) {
  
  let fetchUrl = process.env.BACKEND_API_SERVER_URL + url;
  let fetchProps: FetchProps = {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
    },
  };
  if (hasToken) {
    fetchProps.headers['Authorization'] = `Bearer ${getToken()}`;
  };

  const response = await fetch(fetchUrl, fetchProps);
  if (!response.ok) {
    if(response.status in customErrorMessage) {
      throw new Error(customErrorMessage[response.status]);
    };
    throw new Error(response.statusText);
  };
  return await response.json();
}
```

### features/projects/actions.ts

プロジェクト一覧を取得のアクションを作成。

```typescript:features/projects/actions.ts
'use server';

import fetchGet from "@/util/fetch/get";

export async function getProjects() {
    try{
      return await fetchGet({
        url: '/tms/projects',
        hasToken: true,
      });
    } catch (error) {
      console.error(error);
    }
}
```



### components/cards/LinkCardWithAvatar.tsx
次にプロジェクト一覧に並べるカードコンポーネントを作成。
写真付き、タイトルと説明のカード。

```typescript:components/cards/LinkCardWithAvatar.tsx
import Link from "next/link";

type Props = {
  object: {
    href: string,
    title: string,
    text: string,
    url: string
  }
}

export default function LinkCardWithAvatar({object} : Props) {
  
  return (
    <Link
      href={object.href}
      className="flex-row card hover:bg-base-content/50 bg-base-content/20 rounded-xl mb-4 items-center p-4 lg:mb-0 min-w-[23rem]"
    >
      <div className="avatar">
        <div className="mask mask-squircle w-16">
          <img src={object.url} />
        </div>
      </div>
      <div className="flex-col ml-4">
        <h2 className="text-lg">{object.title}</h2>
        <p className="text-base-content text-sm">{object.text}</p>
      </div>
    </Link>
  )
}
```

### app/nextodo/page.tsx
ここまで来てやっと一覧ページを作成。
ログインしているユーザーのプロジェクト一覧を取得し、
各プロジェクトへ推移するリンクカードとして配置。
画面サイズによってカードを「スマホ１列」→「広げると２列」と切り替えしています。

```typescript:app/nextodo/page.tsx
import LinkCardWithAvatar from "@/components/cards/LinkCardWithAvatar";
import fetchGet from "@/util/fetch/get";

export default async function Page() {

  const ProjectList = async () => {
    try{
      const projects = await fetchGet({
        url: '/tms/projects/',
        hasToken: true,
      });
      return await projects.map((project: any) => (
        <LinkCardWithAvatar
          key={project.id}
          object={{
            href: `/nextodo/${project.id}`,
            title: project.name,
            text: project.description,
            url: project.image_url
          }}
        />
      ));
    }catch(e){
      return <p className="text-error">エラーが発生しました。</p>;
    }
  }

  return (
    <>
      <div className="p-8 w-full h-full">
        <h1 className="text-4xl text-center mb-10">This is ProjectList</h1>
          <div className="md:flex md:flex-wrap md:flex-row flex-col gap-4 mx-auto justify-between max-w-3xl">
            <ProjectList />
          </div>
      </div>
    </>
  );
}
```

<img width="65%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/1c2aa7d5-dbfa-c31c-e942-53561e593586.png">

<img width="30%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/ba4f51e4-b401-1e67-d6e5-0491b477e43e.png">


## サイドバー
続いてサイドバーにもプロジェクト一覧のリンクカードを配置します。

### components/cards/AsideLinkCardWithAvatar.tsx

まずは同様にリンクカードコンポーネントを作成。
カレント表示も対応するため、pathnameから判別するように設定。

```typescript:components/cards/AsideLinkCardWithAvatar.tsx
import Link from "next/link";

type Props = {
  pathname?: string,
  object: {
    href: string,
    title: string,
    url: string
  }
}

export default function AsideLinkCardWithAvatar({pathname, object} : Props) {
  
  const isCurrent = pathname && pathname.includes(object.href);
  const addWrapClass = isCurrent ? " bg-base-content/70" : "";

  return (
    <Link
      href={object.href}
      className={"flex-row card hover:bg-base-content/50 bg-base-content/20 rounded-xl mb-4 items-center p-2 min-w-[10rem]" + addWrapClass}
    >
      <div className="avatar">
        <div className="mask mask-squircle w-10">
          <img src={object.url} />
        </div>
      </div>
      <div className="flex-col ml-2">
        <h2 className="text-md">{object.title}</h2>
      </div>
    </Link>
  )
}
```

### components/layouts/aside-parts/LinkList.tsx

サイドバーの中の、リンクカード群をコンポーネントとして作成。
オブジェクト配列を受け取って、先ほど作ったリンクカードコンポーネントを配置していきます。
カレント表示のため、URLのパス名をusePathnameを使って取得します。

https://nextjs.org/docs/app/api-reference/functions/use-pathname

```typescript:components/layouts/aside-parts/LinkList.tsx
"use client";
import { usePathname } from 'next/navigation';
import AsideLinkCardWithAvatar from '@/components/cards/AsideLinkCardWithAvatar';

type Project = {
  id: string,
  name: string,
  description: string,
  image_url: string
}
type Props = {
  projects: Project[]
}

export default function LinkList({projects} : Props) {
  const pathName = usePathname();

  return (
    <>
      {projects.map((project: Project) => (
          <AsideLinkCardWithAvatar
            key={project.id}
            pathname={pathName}
            object={{
              href: `/nextodo/${project.id}`,
              title: project.name,
              url: project.image_url
            }}
          />
        ))}
    </>
  );
}
```



### components/layouts/Aside.tsx

上記で作成したリンクカード群のコンポーネントをサイドバーに配置。
データ取得のために、プロジェクト一覧ページの際に作成した、プロジェクト一覧取得の関数を使いました。

Logo　と　CreateForm はこの後記載します。

```typescript:components/layouts/Aside.tsx
import CreateForm from '@/features/projects/CreateForm';
import LinkList from './aside-parts/LinkList';
import Logo from './aside-parts/Logo';
import { getProjects } from '@/features/projects/actions';

export default async function Aside() {
  return (
    <aside className="menu px-2 py-8 w-48 min-h-full bg-base-200 justify-between">
      <div>
        <Logo />
        <LinkList projects={await getProjects()} />
      </div>
      <CreateForm/>
    </aside>
  );
}
```

### components/layouts/aside-parts/Logo.tsx

Logoは文字だけにしています。
ヘッダーにも同様のものがあるので、サイドバー展開時は非表示。

```typescript:components/layouts/aside-parts/Logo.tsx
import Link from 'next/link';

export default function Logo() {
  return (
    <div className='text-center mb-8 lg:hidden'>
      <Link href="/nextodo" className="text-xl">
        Nextodo
      </Link>
    </div>
  );
}
```

## プロジェクト追加機能

### features/projects/actions.ts
まずはプロジェクト作成のアクションを作成。
名前と説明をPOSTすれば、トークン所有者のプロジェクトとして追加されます。

```typescript:features/projects/actions.ts
export type State = {
  message: string,
};

export async function createProject(prevState: State, formData: FormData): Promise<State> {
  try {
    await fetchPost({
      url: '/tms/projects/',
      hasToken: true,
      params: {
        name: formData.get('name'),
        description: formData.get('description'),
      },
      customErrorMessage: {},
    });
    prevState.message = 'success';
    return prevState;
  }catch (error) {
    console.error(error);
    prevState.message = 'error';
    return prevState;
  }
}
```

### components/modals/CommonModal.tsx

各所で使い回し出来るモーダルコンポーネントを作成。
children引数を使いフォームやボタンなどを包む形とします。

```typescript:components/modals/CommonModal.tsx
export default function CommonModal({
  children,
  dialog,
  title,
  text,
}:{
  children: React.ReactNode,
  dialog: React.RefObject<HTMLDialogElement>,
  title: string,
  text?: string,
}) {
  const closeHandler = () => {
    dialog.current?.close();
  }

  return (
    <dialog className="modal" ref={dialog}>
      <div className="modal-box text-center">
        <h3 className="font-bold text-lg mb-4">{ title }</h3>
        { text && (
          <p className="mb-4">
            { text }
          </p>
        )}
        <button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={closeHandler} >✕</button>
        { children }
      </div>
      <form method="dialog" className="modal-backdrop">
        <button>close</button>
      </form>
    </dialog>
  );
}
```



### features/projects/CreateForm.tsx

サイドバー下部にプロジェクト追加ボタンを設置。
モーダルにて、名前・説明を入力して追加とします。
プロジェクト追加後は、再読み込み＋モーダルクローズ＋各項目をクリア。

```typescript:features/projects/CreateForm.tsx
'use client';

import { createProject } from "./actions";
import CommonModal from "@/components/modals/CommonModal";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";



export default function CreateForm() {

  const router = useRouter();
  const dialog = useRef<HTMLDialogElement>(null);
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [message, setMessage] = useState('');

  const submitAction = async (formData: FormData) => {
    setMessage('');
    const result =  await createProject({message:message}, formData);
    if(result.message == 'error'){
      setMessage('エラーが発生しました。');
      return;
    }
    router.refresh();
    dialog.current?.close();
    setName('');
    setDescription('');
    return;
  }
  const text = "プロジェクト名と説明を入力してください。";
  
  return (
    <>
      <button className="btn btn-outline" onClick={ () => dialog.current?.showModal() }>+</button>
      <CommonModal
        dialog={dialog}
        title="プロジェクト作成"
        text={text}
      >
        <form action={submitAction} className="flex flex-col">
          <input
            type="text"
            name="name"
            className="input input-bordered mb-4"
            placeholder="プロジェクト名"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
          <textarea
            name='description'
            className="textarea h-24 textarea-bordered mb-4"
            placeholder="プロジェクトの説明"
            value={description}
            onChange={(e) => setDescription(e.target.value)}
          ></textarea>
          <button className="btn btn-primary">作成</button>
          {message && (
            <p className="text-red-500 pt-4">{message}</p>
          )}
        </form>
      </CommonModal>
    </>
  )
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3680973/8f9c652d-9433-1947-0019-9347f3c11c87.png)



## さいごに
・触りのバックエンドへデータを送り非同期処理を行うところまで出来ました。
・最低限の状態から、フォームに、React-Hook-FormやZodなど導入して使える状態に少しずつしていこうと思います。
・全てを記事にしていくと量が多くなるので「やってみた」はここまでにして、今後は通常通り１つずつテーマを立てて記事を書いていきたいと思います。

## 参考

https://nextjs.org/docs