コンテンツ

Next.jsでTrello風のTodoタスク管理アプリを作ってみた | 第2回

2023/12/17に公開

2023/12/29

React
Tailwind CSS
Next.js
Next.jsでTrello風のTodoタスク管理アプリを作ってみた | 第2回

はじめに

本記事は、Next.jsでTrello風のTodoタスク管理アプリを作ってみた | 第1回に続く後編です。

前記事ではNext.jsのプロジェクトファイル作成まで説明しました。こちらでは各種ライブラリを使用しながら実際にコードを書いていきましょう。

Trello風のTodoタスク管理アプリの最終的なイメージは、前記事で紹介していますので、今一度ご確認ください。

Next.jsの基本的なディレクトリ構成

Next.jsの基本的なディレクトリ構成
Next.jsの基本的なディレクトリ構成

まずは、Next.jsアプリの全体像を掴むために、ディレクトリ構成を説明します。

.
└── app
    ├── Component
    │   ├── Container.tsx
    │   ├── Header.tsx
    │   ├── SortableContainer.tsx
    │   └── SortableItem.tsx
    ├── favicon.ico
    ├── globals.css
    ├── layout.tsx
    ├── page.module.css
    └── page.tsx

appフォルダ

以前のNext.jsではプロジェクト開始時にpagesというディレクトリが作成されていましたが、最新のNext.js 13からはappと呼ばれるディレクトリが作成されるようになりました。appフォルダ配下にアプリを構成するコンポーネントやレイアウトファイルなどがあります。

Componentフォルダ

componentsには、その名の通りアプリケーション全体で使うコンポーネントを入れます。今回はアプリケーションの規模が小さいため、これ以上ディレクトリを分けていませんが、大規模なアプリケーションになるとさらにコンポーネントの特性によってフォルダを分けることもあります。componentsフォルダ配下には、アプリで使用するヘッダーやTodoアイテムなどのコンポーネントがあるのが分かりますね。

layout.tsxファイル

layout.tsxは、複数ページで使用する共通項目(ヘッダー、フッター、ナビゲーションなど)をレイアウトとして設定するファイルです。レイアウトに設定した内容はappフォルダ以下の全てのページに適用されます。

page.tsxファイル

page.tsxはアプリ内の各ページに対応するファイルです。新規にページを追加する場合、appフォルダ以下に新たなディレクトリを作成し、その中でpage.tsxを作成します。画面の表示項目を修正する場合は、基本的にpage.tsxを触ることになります。

他にもファイルやディレクトリがありますが、最低限上記の項目を押さえておけば大丈夫です。

ドラッグ&ドロップライブラリ「dnd kit」の導入

続いて、Todoタスク管理アプリを開発する際に必要なドラッグ&ドロップライブラリ「dnd kit」をインストールしましょう。インストール手順は公式ドキュメントにも載っています。

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

dnd kitを入れることで、Reactアプリケーションでドラッグ&ドロップの機能を簡単に実装することができます。Todoタスクアイテムの順序を変えたり、ステータスを変えたりする部分をイメージするとわかりやすいと思います。

Reactアプリケーションでドラッグ&ドロップの機能
Reactアプリケーションでドラッグ&ドロップの機能

各ファイルの実装を解説

知識のインプットやライブラリのインストールが完了したので、各ファイルの中身を見ていきましょう。

共通レイアウト:layout.tsxの実装

import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Header from './Component/Header'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

// metaタグの情報
export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body className={inter.className}>
        <Header />
        {children}
      </body>
    </html>
  )
}

RootLayoutはデフォルトでエクスポートされる関数コンポーネントです。アプリケーションの共通のレイアウトとして、ComponentフォルダからimportしたHeaderを定義しています。Header.tsxの中身は以下の通りです。

const Header = () => {
  return (
    <nav className='flex items-center justify-between flex-wrap p-6 w-full'>
      <div className='flex items-center flex-shrink-0 text-white mr-6'>
        <svg
          className='fill-current h-8 w-8 mr-2'
          width='54'
          height='54'
          viewBox='0 0 54 54'
          xmlns='http://www.w3.org/2000/svg'
        >
          <path d='M13.5 22.1c1.8-7.2 6.3-10.8 13.5-10.8 10.8 0 12.15 8.1 17.55 9.45 3.6.9 6.75-.45 9.45-4.05-1.8 7.2-6.3 10.8-13.5 10.8-10.8 0-12.15-8.1-17.55-9.45-3.6-.9-6.75.45-9.45 4.05zM0 38.3c1.8-7.2 6.3-10.8 13.5-10.8 10.8 0 12.15 8.1 17.55 9.45 3.6.9 6.75-.45 9.45-4.05-1.8 7.2-6.3 10.8-13.5 10.8-10.8 0-12.15-8.1-17.55-9.45-3.6-.9-6.75.45-9.45 4.05z' />
        </svg>
        <span className='font-semibold text-xl tracking-tight'>My Kanban App</span>
      </div>
      <div className='block lg:hidden'>
        <button className='flex items-center px-3 py-2 border rounded text-teal-200 border-teal-400 hover:text-white hover:border-white'>
          <svg
            className='fill-current h-3 w-3'
            viewBox='0 0 20 20'
            xmlns='http://www.w3.org/2000/svg'
          >
            <title>Menu</title>
            <path d='M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z' />
          </svg>
        </button>
      </div>
      <div className='w-full block flex-grow lg:flex lg:items-center lg:w-auto'>
        <div className='text-sm lg:flex-grow'>
          <a
            href='https://www.tearn.jp/'
            className='block mt-4 lg:inline-block lg:mt-0 text-teal-200 hover:text-white mr-4'
            target={'_blank'}
          >
            Docs
          </a>
          <a
            href='https://www.tearn.jp/'
            className='block mt-4 lg:inline-block lg:mt-0 text-teal-200 hover:text-white mr-4'
            target={'_blank'}
          >
            Examples
          </a>
          <a
            href='https://www.tearn.jp/'
            className='block mt-4 lg:inline-block lg:mt-0 text-teal-200 hover:text-white'
            target={'_blank'}
          >
            Blog
          </a>
        </div>
        <div>
          <a
            href='#'
            className='inline-block text-sm px-4 py-2 leading-none border rounded text-white border-white hover:border-transparent hover:text-teal-500 hover:bg-white hover:text-black mt-4 lg:mt-0'
          >
            Download
          </a>
        </div>
      </div>
    </nav>
  )
}
export default Header

CSSに関してはTailwind CSS(CSSフレームワーク)を使用しているため、className='○○○'と書くだけでシンプルに、コード量少なく実装することができます。

メイン画面:page.tsxの実装

import Container from './Component/Container'
import styles from './page.module.css'

export default function Home() {
  return (
    <main className={styles.main}>
      <Container />
    </main>
  )
}

こちらはメインで表示する画面です。コードの詳細は、importしているContainer.tsxに実装しているため、page.tsxはコード量が少なくなっています。Container.tsxの実装は以下の通りです。

'use client'

import React, { useState } from 'react'
import {
  DndContext,
  DragOverlay,
  closestCorners,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  UniqueIdentifier,
  DragStartEvent,
  DragOverEvent,
  DragEndEvent,
} from '@dnd-kit/core'
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import SortableContainer from './SortableContainer'
import Item from './Item'

const Contaienr = () => {
  // Todoタスクとして管理するアイテムリスト
  const [items, setItems] = useState<{
    [key: string]: string[]
  }>({
    container1: ['英単語を覚える', '犬の散歩をする', '会話する'],
    container2: ['日用品を買う', 'お菓子を食べる'],
    container3: ['サッカーを観戦する', '服を買う', 'テレビを観る', '夕飯を作る'],
    container4: [],
  })

  //アイテムリストのリソースid
  const [activeId, setActiveId] = useState<UniqueIdentifier>()

  // ドラッグの開始、移動、終了時に許可するインプット
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  )

  const findContainer = (id: UniqueIdentifier) => {
    if (id in items) {
      return id
    }
    return Object.keys(items).find((key: string) => items[key].includes(id.toString()))
  }

  const handleDragStart = (event: DragStartEvent) => {
    const { active } = event
    const id = active.id.toString()
    setActiveId(id)
  }

  const handleDragOver = (event: DragOverEvent) => {
    const { active, over } = event
    const id = active.id.toString()
    const overId = over?.id

    if (!overId) return
    const activeContainer = findContainer(id)
    const overContainer = findContainer(over?.id)

    if (!activeContainer || !overContainer || activeContainer === overContainer) {
      return
    }

    setItems((prev) => {
      const activeItems = prev[activeContainer]
      const overItems = prev[overContainer]
      const activeIndex = activeItems.indexOf(id)
      const overIndex = overItems.indexOf(overId.toString())
      let newIndex
      if (overId in prev) {
        newIndex = overItems.length + 1
      } else {
        const isBelowLastItem = over && overIndex === overItems.length - 1
        const modifier = isBelowLastItem ? 1 : 0
        newIndex = overIndex >= 0 ? overIndex + modifier : overItems.length + 1
      }

      return {
        ...prev,
        [activeContainer]: [...prev[activeContainer].filter((item) => item !== active.id)],
        [overContainer]: [
          ...prev[overContainer].slice(0, newIndex),
          items[activeContainer][activeIndex],
          ...prev[overContainer].slice(newIndex, prev[overContainer].length),
        ],
      }
    })
  }

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event
    const id = active.id.toString()
    const overId = over?.id
    if (!overId) return
    const activeContainer = findContainer(id)
    const overContainer = findContainer(over?.id)

    if (!activeContainer || !overContainer || activeContainer !== overContainer) {
      return
    }

    const activeIndex = items[activeContainer].indexOf(id)
    const overIndex = items[overContainer].indexOf(overId.toString())

    if (activeIndex !== overIndex) {
      setItems((items) => ({
        ...items,
        [overContainer]: arrayMove(items[overContainer], activeIndex, overIndex),
      }))
    }
    setActiveId(undefined)
  }

  return (
    <div className='flex flex-row mx-auto'>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCorners}
        onDragStart={handleDragStart}
        onDragOver={handleDragOver}
        onDragEnd={handleDragEnd}
      >
        <SortableContainer id='container1' label='TO DO' items={items.container1} />
        <SortableContainer id='container2' label='IN PROGRESS' items={items.container2} />
        <SortableContainer id='container3' label='REVIEW' items={items.container3} />
        <SortableContainer id='container4' label='DONE' items={items.container4} />

        <DragOverlay>{activeId ? <Item id={activeId} /> : null}</DragOverlay>
      </DndContext>
    </div>
  )
}

export default Contaienr

Container.tsxに登場する主なメソッドは以下の通りです。

handleDragStart関数は、ドラッグが開始されたときに発火します。ドラッグされたアイテムのidを取得し、setActiveId関数を使用してアクティブな要素のidを更新します。

handleDragOver関数は、ドラッグ可能なアイテムがドロップ可能なコンテナの上に移動したときに発火します。ドラッグされた要素とドロップ先の要素のidを取得し、それぞれが属するコンテナを特定します。移動元と移動先のコンテナが異なる場合、アイテムの位置を変更するためにsetItems関数を使用します。

handleDragEnd関数は、ドラッグが終了したときに発火します。ドラッグされたアイテムとドロップ先のアイテムのidを取得し、それぞれが属するコンテナを特定します。移動元と移動先のコンテナが同じ場合、アイテムの位置を変更するためにsetItems関数を使用します。

最後に、return()に表示するHTMLのタグを記述していきます。DndContextコンポーネントでドラッグ&ドロップのコンテキストを作成し、タグ内で管理対象のコンテナをレンダリングしています。SortableContainerは各コンテナを表し、DragOverlayはドラッグ中の要素をオーバーレイ表示します。

SortableContainer.tsxの実装は以下の通りです。

'use client'

import { useDroppable } from '@dnd-kit/core'
import { rectSortingStrategy, SortableContext } from '@dnd-kit/sortable'
import SortableItem from './SortableItem'

const SortableContainer = ({
  id,
  items,
  label,
}: {
  id: string
  items: string[]
  label: string
}) => {
  const { setNodeRef } = useDroppable({
    id,
  })
  return (
    <div className='w-[calc(33%-5px)]'>
      <SortableContext id={id} items={items} strategy={rectSortingStrategy}>
        <div
          ref={setNodeRef}
          className='w-64 min-h-full m-4 bg-gray-200 p-5 mt-2 rounded-md shadow-md'
        >
          <p className='text-md font-bold text-sky-950'>{label}</p>
          {items.map((id: string) => (
            <SortableItem key={id} id={id} />
          ))}
        </div>
      </SortableContext>
    </div>
  )
}

export default SortableContainer

SortableContextはソートを行うためのコンテキストであり、ソート対象をタグ内側に書く必要があります。今回は親コンポーネントから受け取ったitemsSortableItemに詰めて表示しています。

'use client'

import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { UniqueIdentifier } from '@dnd-kit/core'
import Item from './Item'

const Item = ({ id }: { id: UniqueIdentifier }) => {
  return (
    <div className='w-full h-[50px] flex items-center justify-center my-2.5 border bg-white	shadow-md rounded-lg'>
      {id}
    </div>
  )
}

const SortableItem = ({ id }: { id: UniqueIdentifier }) => {
  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })

  return (
    <div
      ref={setNodeRef}
      style={{ transform: CSS.Transform.toString(transform), transition }}
      {...attributes}
      {...listeners}
    >
      <Item id={id} />
    </div>
  )
}

export default SortableItem

実装は以上で終わりです。

ローカルサーバーを起動してアプリケーションの動作を確認しましょう。

ローカルサーバーで起動

以下コマンドは、Next.jsアプリをローカルホストの3000ポートで起動するためのものです。

npm run dev

コマンド実行後、http://localhost:3000 にブラウザからアクセスしてみましょう。

Todoタスク管理アプリ
Todoタスク管理アプリ

見事にTodoタスク管理アプリが表示されましたね。

最後に

いかがだったでしょうか。

今回はNext.jsでTrello風のTodoタスク管理アプリを作ってみた | 第1回との二部構成で簡易的なハンズオンを紹介しました。

本記事と合わせて、React(Next.js)の特徴、10分で宣言的UIと命令的UIの違いを完全理解する | サンプルコード付も理解しておくと良いでしょう。

トップへ戻る

おすすめの記事

Next.jsでTrello風のTodoタスク管理アプリを作ってみた | 第1回

コンテンツ

Next.jsでTrello風のTodoタスク管理アプリを作ってみた | 第1回

Atsu

2023/11/29

目次