【Next.js】Githubのような勉強記録アプリ④:LocalStorageに詳細を追加・変更しよう

Programming

前回【Next.js】Githubのような勉強記録アプリ③:Gridを使って勉強時間を可視化するに続き、今回はLocalStorageに記録されている勉強内容の詳細を追加・変更する機能を実装しました。

前回までは勉強時間・勉強した日付のみ管理していましたが、今回はさらに、勉強内容の詳細・メモ・チェックリストなど、自分用の情報を記録できるようにしています。また、今回はモーダルを利用して詳細画面を表示し、編集できるようにしました。

この記事でわかること
  • LocalStorageデータの更新方法
  • onChange() の基本的な使い方
  • モーダルのOpen / Close
  • textareaを使ったデータ編集
  • useRefを使ったfocus制御

まずは、LocalStorageのデータの編集方法について説明します。

LocalStorageのデータ編集

以前【Next.js】Githubのような勉強記録アプリ②:LocalStorageを活用したデータ保存でLocalStorageについて少し説明しましたが、保存されているデータを修正する場合も、setItem を利用してデータを上書きします。

データ修正(setItem)

データ保存時に使用していた setItem を使って、データを上書きします。

localStorage.setItem('studyTime', '120');
localStorage.setItem('studyTime','200'); //studyTimeが120から200へ変更できます。

このように、同じKeyを指定して再度 setItem を実行することで、データを更新できます。

実際例

このLocalStorageの更新処理を利用して、実際に勉強記録アプリへ「詳細編集機能」を追加していきます。

コンポーネント構成

GrassGridコンポーネントの中にある、対象の日付(GrassCell)をクリック

必要な情報をモーダルコンポーネントに渡す

モーダルコンポーネントで詳細や編集機能を追加

処理の流れ

実際の処理の流れは以下になります。

  1. 対象の日付をクリック
  2. 選択した日付のモーダルを表示
  3. LocalStorageから既存データを取得
  4. textareaへ内容を表示
  5. 編集内容を更新
  6. SaveボタンでLocalStorageへ再保存

GrassGridコンポーネント

GrassGridの中にGrassCellで描いた1年分の勉強データが入っています。ユーザーは必要な日付(GrassCell)をクリックして詳細を確認・編集できます。

選択した日付のモーダルを表示

選択された日付と、モーダルの表示状態をStateで管理します。isModalOpentrueの時だけDetailModalを表示するようにしています。

const [selectedDate, setSelectedDate] = useState<string | null>(null); 
const [isModalOpen, setIsModalOpen] = useState(false); //モーダルオープン(基本はFalseで閉じる)

 return (
          <GrassCell key={date} date={date} studyData={records} onClick={handleCellClick} />
            );
          })}
          //isModalとselectedDateがある場合、DetailModalを表示
          {isModalOpen && selectedDate && ( 
            <DetailModal date={selectedDate} onClose={() => setIsModalOpen(false)} />
           )}
        </div>
      ))}
onClose()とは?

モーダルを閉じるための関数です。今回は GrassGrid 側でモーダルの表示状態を管理しているため、DetailModal 側から親コンポーネントのStateを変更する必要があります。そのため、props経由で onClose を渡しています。

<DetailModal date={selectedDate} onClose={() => setIsModalOpen(false)}/>

対象の日付をクリック

対象の日付をクリックすると、選択した日付のデータが存在するか確認し、モーダルを表示します。データが存在する場合のみ、選択された日付を保存し、モーダルをOpenしています。


const handleCellClick = (date:string) => {
    const studyData = getStudyData();
    const records = studyData[date] || [];     
    if(records.length > 0){
        setSelectedDate(date);
        setIsModalOpen(true);
    }
}

GrassGridの全体コード

今回の必要な部分だけ抜粋しました。

コードを見る

export default function GrassGrid() {

  const tates = getDates(365);
  const weeks = chunkDates(tates);
  const studyData = getStudyData();
  const [selectedDate, setSelectedDate] = useState<string | null>(null); 
  const [isModalOpen, setIsModalOpen] = useState(false); 


const handleCellClick = (date:string) => {
    const studyData = getStudyData();
    const records = studyData[date] || [];     
    if(records.length > 0){
        setSelectedDate(date);
        setIsModalOpen(true);
    }

}

  return (
    <div className="flex gap-1">
      {weeks.map((week, i) => (
        <div key={i} className="flex flex-col gap-1">
          {week.map((date) => {
            const records = studyData[date] || []; 
            return (
              <GrassCell key={date} date={date} studyData={records} onClick={handleCellClick} />
            );
          })}
          {isModalOpen && selectedDate && ( 
            <DetailModal date={selectedDate} onClose={() => setIsModalOpen(false)} />
           )}
        </div>
      ))}
    </div>
  );
}

DetailModalコンポーネント

次に、選択した日付の詳細情報を表示する DetailModalコンポーネントを作成します。propsとして、選択された日付・モーダルを閉じる関数を受け取っています。

export default function DetailModal({date, onClose}:{date:string, onClose:() => void}){}

LocalStorageから既存データを取得

受け取った日付(date)を利用して、該当する勉強データを取得します。もし、データが存在しない場合でもエラーにならないように、空配列を利用しています。

const studyData = getStudyData(); const records = studyData[date] || [];

編集内容を更新

次に、詳細情報や編集状態を管理するためのState変数を宣言します。

isEditingは、閲覧モード・編集モードを切り替えています。また、detailが存在しない場合でもエラーにならないように、空文字を設定しています。

  const [isEditing, setIsEditing] = useState(false);
  const [detailData, setDetailData] = useState(records[0]?.detail || ""); 
  const textAreaRef = useRef<HTMLTextAreaElement>(null);
textareaへfocusを当てる

今回はEditボタンを押した時に、自動で textarea へfocusできるようにしました。

const textAreaRef = useRef<HTMLTextAreaElement>(null);

Editボタンを押した時に実行される処理です。

    const handleEdit = () => {
        setIsEditing(true);
        textAreaRef.current?.focus();
    }

focus()を利用することで、textareaへ自動でカーソルを移動できます。

onChange()を利用してテキストを編集

textareaの内容はonChange()を利用して更新しています。入力内容が変更される度にonChange()が実行され、detailDataのStateを更新しています。

<textarea
  value={detailData}
  onChange={(e) =>
    setDetailData(e.target.value)
  }
/>

Save処理

Saveボタンでは、編集した内容をLocalStorageへ保存します。onChange()によって更新された detailDataを利用して、LocalStorageへ再保存しています。

    const handleSave = () => {
       //既存データを展開しながら、detail のみ更新しています。
        const updatedRecord = {
            ...records[0],
            detail: detailData,
        };
        
        //LocalStorageへ再保存します
        setDetailData(updatedRecord.detail || "");
        localStorage.setItem("studyData", JSON.stringify({...studyData, [date]: [updatedRecord]}));
        
        //最後に、編集モードを終了します。
        setIsEditing(false);
    }

DetailModalの全体コード

全体コードは以下になります。

コードを見る
  
import { getStudyData } from "@/lib/data";
import { formatDurationShort } from "@/lib/format";
import { useRef, useState } from "react";


export default function DetailModal({date, onClose}:{date:string, onClose:() => void}){
    const studyData = getStudyData();
    const records = studyData[date] || [];
    const [isEditing, setIsEditing] = useState(false);
    const [detailData, setDetailData] = useState(records[0]?.detail || ""); 
    const textAreaRef = useRef<HTMLTextAreaElement>(null);

    const handleEdit = () => {
        setIsEditing(true);
        textAreaRef.current?.focus();
    }


    const handleSave = () => {
        const updatedRecord = {
            ...records[0],
            detail: detailData,
        };
        setDetailData(updatedRecord.detail || "");
        localStorage.setItem("studyData", JSON.stringify({...studyData, [date]: [updatedRecord]}));
        setIsEditing(false);
    }

    return(
        <div className="fixed inset-0  flex items-center justify-center" onClick={onClose}>
            <div className="bg-zinc-50 border border-gray-300 p-4 rounded  w-1/3 m-auto h-auto overflow-y-auto"
                onClick={(e) => e.stopPropagation()}
            >
                    <h2 className="text-xl font-bold mb-4">Study Details for {date}</h2>
                <div>
                    {records.length === 0 ? (
                        <p>No study records for {date}</p>
                    ) : (
                        records.map((record, index) => (
                            <div key={index} className="py-2">
                                <h3 className="font-semibold">TITLE : {record.title}</h3>
                                <p className="text-gray-600 border-b mb-2">Duration : {formatDurationShort(record.duration)}</p>
                                <textarea className="w-full h-20 p-2 border rounded" ref={textAreaRef} readOnly={!isEditing} value={detailData} onChange={(e) => setDetailData(e.target.value)} />
                                <div>
                                    {isEditing ? (
                                        <button type="button" className="bg-green-600 text-white px-4 py-2 rounded cursor-pointer hover:bg-green-500" onClick={handleSave}>
                                            Save
                                        </button>
                                    ) : (
                                    <button type="button" className="bg-blue-600 text-white px-4 py-2 rounded cursor-pointer hover:bg-blue-500" onClick={handleEdit}>
                                        Edit
                                    </button>
                                    )}
                                    <button type="button" className="bg-gray-600 text-white px-4 py-2 rounded ml-2 cursor-pointer hover:bg-gray-500" onClick={onClose}>
                                        Done
                                    </button>
                                </div>
                            </div>
                        ))
                    )}
                </div>
            </div>
        </div>
    )
}

終わりに

今回で、Github風の勉強記録アプリの基本機能は一通り実装できました。まだUI / UX部分については改善できそうな部分も多いため、今後は細かいデザインや操作性も少しずつ調整していきたいと思います。今回は、LocalStorageを利用してデータ管理を行いました。

シンプルな構成ですが、Reactらしい考え方についてかなり勉強になりました。また、今回はLocalStorageを利用しているため、サーバーやDBを用意しなくても簡単にデータ管理できます。必要に応じてDBと連携することで、さらに実用的なアプリケーションへ発展できると思います。

ぜひ、自分なりにカスタマイズしながら作ってみてください。

タイトルとURLをコピーしました