🔃

react router v7に入門してみた

2025/01/19に公開

react router v7に入門してみた

私は、remixを使って色々遊んでいましたが、今回のremixとreact router v7の統合を機に、reacct router v7に乗り換えてみました。
試しに、todoアプリケーションを作ってみたので、共有したいと思います。

バックエンドの参考記事はこちら
https://zenn.dev/keita_f/articles/e8c198f4e060f2

アプリケーションの立ち上げ

アプリケーションの立ち上げは、公式ドキュメントに従って行います。
https://reactrouter.com/start/framework/installation

なお、UIにはshadcnを使いました。react19を使っていますが、特に問題なく動いています。

https://ui.shadcn.com/

ルーティング

アプリケーションが立ち上がると、色々ディレクトリやファイルが生成されますが、まずはルーティングの設定を確認します。
基本的な説明は、以下の公式ドキュメントを確認して下さい。
https://reactrouter.com/start/framework/routing
また、ここからは、私が作ったtodoアプリケーション(完成版)のディレクトリ構造をベースに説明します。
完成品のディレクトリ構造は以下のようになりました。

ディレクトリ構造
.
├── app.css
├── auth
│   ├── change_password.tsx
│   ├── layout.tsx
│   ├── login.tsx
│   ├── logout.tsx
│   └── signup.tsx
├── components
│   ├── component
│   │   ├── CustomTimePicker.tsx
│   │   ├── DateTimePicker.tsx
│   │   ├── DeleteTodoButton.tsx
│   │   ├── DeleteUserButton.tsx
│   │   ├── FileDisplay.tsx
│   │   ├── FileUploadForm.tsx
│   │   ├── Footer.tsx
│   │   ├── LogoutButton.tsx
│   │   ├── ProcessDateTimePicker.tsx
│   │   ├── SearchForm.tsx
│   │   ├── Sidebar.tsx
│   │   ├── SortableEditProcess.tsx
│   │   └── SortableProcess.tsx
│   └── ui
│       ├── alert-dialog.tsx
│       ├── alert.tsx
│       ├── avatar.tsx
│       ├── button.tsx
│       ├── calendar.tsx
│       ├── card.tsx
│       ├── checkbox.tsx
│       ├── dialog.tsx
│       ├── drawer.tsx
│       ├── dropdown-menu.tsx
│       ├── hover-card.tsx
│       ├── input.tsx
│       ├── label.tsx
│       ├── popover.tsx
│       ├── resizable.tsx
│       ├── scroll-area.tsx
│       ├── separator.tsx
│       ├── sheet.tsx
│       ├── sidebar.tsx
│       ├── skeleton.tsx
│       ├── table.tsx
│       ├── textarea.tsx
│       └── tooltip.tsx
├── data
│   ├── auth.server.ts
│   ├── date.ts
│   ├── session.server.ts
│   └── todo.server.ts
├── files
│   └── avatar
│       └── default_avatar.png
├── globals.css
├── hooks
│   └── use-mobile.tsx
├── lib
│   └── utils.ts
├── root.tsx
├── routes
│   ├── about.tsx
│   └── home.tsx
├── routes.ts
├── todo
│   ├── create.tsx
│   ├── delete_todo.tsx
│   ├── edit.tsx
│   ├── layout.tsx
│   ├── todo_uuid.tsx
│   └── todos.tsx
├── type
   ├── auth.ts
│   └── todo.ts
├── user
│   ├── delete_user.tsx
│   ├── layout.tsx
│   ├── user_uuid.tsx
│   └── users.tsx
└── welcome
    ├── logo-dark.svg
    ├── logo-light.svg
    └── welcome.tsx

この中のroutes.tsをみていきます。

app/routes.ts
import {
  type RouteConfig,
  index,
  route,
  layout,
  prefix,
} from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  // routesディレクトリをベースとしたファイルパス
  route("about", "routes/about.tsx"),
  // prefixを設ける+専用dirを設定した場合
  layout("./todo/layout.tsx", [
    ...prefix("todo", [
      index("todo/todos.tsx"),
      route(":todo_uuid", "todo/todo_uuid.tsx"),
      route("create_todo/new", "todo/create.tsx"),
      route("edit/:todo_uuid", "todo/edit.tsx"),
      route("/delete_todo", "todo/delete_todo.tsx"),
    ]),
  ]),
  layout("./auth/layout.tsx", [
    route("auth/login", "./auth/login.tsx"),
    route("auth/logout", "./auth/logout.tsx"),
    route("auth/signup", "./auth/signup.tsx"),
    route("auth/change_password", "./auth/change_password.tsx"),
  ]),
  layout("./user/layout.tsx", [
    ...prefix("user", [
      index("user/users.tsx"),
      route(":user_uuid", "user/user_uuid.tsx"),
      route("/delete_user", "user/delete_user.tsx"),
    ]),
  ]),
] satisfies RouteConfig;

routes.tsの記述方法

  • layout
    子ルートに適用される共通のレイアウトを指定できるものです。
    layoutの書き方
    // レイアウトを記載したファイルを指定
    layout("./todo/layout.tsx", [
      // 子ルートを記載(リスト形式)
    ])
    
  • ...prefix
    子ルートのURLに接頭辞を設定することができます。
    ...prefixの書き方
    layout("レイアウトを記載したファイル", [
      // 設定したいprefixを記載→「/todo」が設定される
      ...prefix("todo"), [
      // 子ルートを記載
      ]
    ])
    
  • index
    ルートとなるURLで表示するページを指定します。
    indexの書き方
    layout("レイアウトを記載したファイル", [
      // 設定したいprefixを記載→「/todo」が設定される
      ...prefix("todo"), [
        index("todo/todos.tsx"), // /todoで表示される
      ]
    ])
    
  • route
    子ルートの各ページを指定します。それぞれのURLで表示する内容に応じて設定します。
    routeの書き方
    layout("レイアウトを記載したファイル", [
      // 設定したいprefixを記載→「/todo」が設定される
      ...prefix("todo"), [
        index("todo/todos.tsx"), // /todoで表示される
        // 「:」の後に指定した部分をファイル名に指定すると、この部分がparamsになります
        route(":todo_uuid", "todo/todo_uuid.tsx"),
        route("create_todo/new", "todo/create.tsx"),
        route("edit/:todo_uuid", "todo/edit.tsx"),
        route("/delete_todo", "todo/delete_todo.tsx"),
      ]
    ])
    

そして、このとき対応するファイルなどが以下のとおりです。

└── todo
    ├── create.tsx
    ├── delete_todo.tsx
    ├── edit.tsx
    ├── layout.tsx
    ├── todo_uuid.tsx
    └── todos.tsx

ファイルベースルーティングではないですが、routes.tsに記載し、それに対応するファイルを作成することでルーティングが構築されます。

user処理

ここでは今回のコードを示します。
基本的には前回の処理とほとんど同じなので、こちらの記事を参照ください。
⚪︎フロントエンド
https://zenn.dev/keita_f/articles/1d86a71c1c28e2
⚪︎バックエンド
https://zenn.dev/keita_f/articles/e8c198f4e060f2

user/layout.tsx
user/layout.tsx
import { Outlet } from "react-router";

export default function UserLayout() {
  return (
    <div className="mt-10">
      <Outlet />
    </div>
  );
}
user/users.tsx
user/users.tsx
import { Link, redirect } from "react-router";

// icon
import { Info } from "lucide-react";

// server
import {
  getAllUser,
  getTokenFromSession,
  requireAccountSession,
} from "../data/auth.server";

// type
import type { Route } from "./+types/users";
import type { User } from "../type/auth";

// ui
import {
  Table,
  TableBody,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "../components/ui/table";

// date
import customDateFormat from "../data/date";

// meta
export function meta({}: Route.MetaArgs) {
  return [
    { title: "User List" },
    {
      property: "og:title",
      content: "content",
    },
    { name: "description", content: "All users are displayed." },
  ];
}

export async function loader({ request }: Route.LoaderArgs) {
  const token = await getTokenFromSession(request);
  const login_user = await requireAccountSession(request);
  if (!login_user) {
    return redirect("/auth/login");
  }
  if (!token) {
    return { users: [] };
  }
  try {
    const users = await getAllUser(token?.access_token);
    return { users };
  } catch (error: unknown) {
    console.error("Failed to fetch users:", error);
    return { users: [] };
  }
}

export default function Users({
  loaderData,
}: {
  loaderData: { users: User[] };
}) {
  const users: User[] = loaderData?.users ?? [];

  // date format
  const formatted_now = customDateFormat(new Date(), "yyyy-MM-dd HH:mm:ss");

  return (
    <div className="lg:m-12 md:m-10 sm:m-3">
      <h1 className="flex justify-center text-3xl font-bold mb-3">User List</h1>
      <div>
        Total
        {users.length == 1 ? (
          <span className="ml-2">{users.length} account</span>
        ) : (
          <span className="ml-2">{users.length} accounts</span>
        )}
      </div>

      {users?.length > 0 ? (
        <Table>
          <TableHeader>
            <TableRow>
              <TableHead className="text-center whitespace-nowrap">
                Details
              </TableHead>
              <TableHead className="w-[100px] text-center whitespace-nowrap">
                Username
              </TableHead>
              <TableHead className="text-center whitespace-nowrap">
                Email
              </TableHead>
              <TableHead className="text-center whitespace-nowrap">
                Active
              </TableHead>
              <TableHead className="text-center whitespace-nowrap">
                Status
              </TableHead>
              <TableHead className="text-center whitespace-nowrap">
                Created at
              </TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {users?.map((user) => (
              <TableRow key={user.username}>
                <TableCell className="whitespace-nowrap flex justify-center">
                  <Link to={`/user/${user.uuid}`}>
                    <Info className="w-[15px]" />
                  </Link>
                </TableCell>
                <TableCell className="whitespace-nowrap">
                  <Link to={`/user/${user.uuid}`}>{user.username}</Link>
                </TableCell>
                <TableCell className="whitespace-nowrap">
                  {user.email}
                </TableCell>
                <TableCell className="whitespace-nowrap">
                  {user.is_active === true ? "active" : "disactive"}
                </TableCell>
                <TableCell className="whitespace-nowrap">
                  {user.is_superuser === true ? "Admin" : "user"}
                </TableCell>
                <TableCell className="whitespace-nowrap">
                  {customDateFormat(
                    new Date(user?.created_at),
                    "yyyy-MM-dd HH:mm:ss"
                  )}
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
          <TableFooter></TableFooter>
        </Table>
      ) : (
        <p>No users found.</p>
      )}
      <div className="flex justify-center mt-2 text-sm text-gray-500">
        A list of at {formatted_now}.
      </div>
    </div>
  );
}
user_uuid.tsx
user/user_uuid.tsx
import { useState } from "react";
import { Form, Link, redirect } from "react-router";

// server
import {
  getUserData,
  requireAccountSession,
  updateUser,
} from "../data/auth.server";

// validation
import { z } from "zod";

// ui
import { Checkbox } from "../components/ui/checkbox";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
} from "~/components/ui/card";
import { Input } from "../components/ui/input";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { Button } from "../components/ui/button";
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "../components/ui/drawer";

// components
import DeleteUserButton from "~/components/component/DeleteUserButton";

// image
import default_avatar from "../files/avatar/default_avatar.png";

// type
import type { User } from "../type/auth";
import type { Route } from "./+types/user_uuid";

// meta
export function meta({ data }: Route.MetaArgs) {
  return [
    { title: `${data?.login_user?.username}さんのページ` },
    {
      property: "og:title",
      content: `${data?.login_user?.username}さんのプロフィール画面`,
    },
    {
      name: "description",
      content: `${data?.login_user?.username}さんのページ`,
    },
  ];
}

// validation
const UpdateUserValidation = z.object({
  username: z.string().min(4, { message: "Username length is 4 at leaset" }),
  email: z.string().email().min(1, { message: "Input your email address." }),
});

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const rawData = Object.fromEntries(formData.entries());
  const user_uuid = formData.get("uuid")?.toString();

  const validationResult = UpdateUserValidation.safeParse(rawData);
  if (!validationResult.success) {
    return new Response(
      JSON.stringify({
        error: validationResult.error.errors,
      }),
      {
        status: 400,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
  const { username, email } = validationResult.data;
  const is_active = formData.get("is_active") === "true";
  const is_superuser = formData.get("is_superuser") === "true";

  if (!user_uuid) {
    return new Response(JSON.stringify({ error: "User ID is required." }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  try {
    await updateUser(
      request,
      user_uuid,
      username,
      email,
      is_active,
      is_superuser
    );
    redirect(`/user/${user_uuid}`);
  } catch (error: unknown) {
    return Response.json({ error: "Failed to update user." });
  }
}

export async function loader({ params, request }: Route.LoaderArgs) {
  const user = await getUserData(request, params.user_uuid);
  const login_user = await requireAccountSession(request);
  return { user, login_user };
}

export default function userDetail_uuid({
  loaderData,
}: {
  loaderData: { user: User; login_user: User };
}) {
  const { user, login_user } = loaderData;

  // state
  const [username, setUsername] = useState<string | undefined>(user?.username);
  const [email, setEmail] = useState<string | undefined>(user?.email);
  const [isActive, setIsActive] = useState<boolean | undefined>(
    user?.is_active
  );
  const [isSuperuser, setIsSuperuser] = useState<boolean | undefined>(
    user?.is_superuser
  );

  const handleIsActiveCheckboxChange = () => {
    setIsActive((prev) => !prev); // 現在の状態を反転
  };

  const handleIsSuperuserCheckboxChange = () => {
    setIsSuperuser((prev) => !prev); // 現在の状態を反転
  };

  return (
    <div className="mx-auto m-5 max-w-[600px]">
      <h1 className="text-3xl mb-3 text-center font-bold">User Infromation</h1>
      <div className="flex h-[70px] items-center">
        <Avatar>
          <AvatarImage src={default_avatar} alt="testImage" />
          <AvatarFallback>test</AvatarFallback>
        </Avatar>
        <div className="flex flex-col ml-3">
          <div>
            <p className="text-2xl font-bold">{user?.username}</p>
          </div>
          <div>
            <p>ID: {user?.uuid}</p>
          </div>
        </div>
      </div>
      <div className="text-[12px] m-2">
        If you want to change your information, please input below fields and
        press <span className="font-bold">"save"</span> button.
      </div>
      <Card className="mt-5 px-3">
        <CardHeader className="px-6 py-3">Change profile</CardHeader>
        <CardDescription className="px-6">
          Input your field you want to change.
        </CardDescription>
        <CardDescription className="px-6 text-[10px]">
          * Now, you can change username and email.
        </CardDescription>
        <CardContent className="mt-3">
          <Form method="post">
            <div>
              <Input type="hidden" name="uuid" value={user?.uuid} />
            </div>
            <p className="mt-2">Username</p>
            <Input
              name="username"
              className="mt-1 w-full"
              placeholder="Username"
              defaultValue={user?.username}
              onChange={(e) => setUsername(e.target.value)}
            />
            <p className="mt-2">Email</p>
            <Input
              name="email"
              className="mt-1 w-full"
              placeholder="Email"
              defaultValue={user?.email}
              onChange={(e) => setEmail(e.target.value)}
            />
            {login_user?.is_superuser === true && (
              <div>
                <div className="mt-2 flex items-center">
                  <Checkbox
                    checked={isActive}
                    className="mr-3"
                    onCheckedChange={handleIsActiveCheckboxChange}
                  />
                  {isActive == true ? "active" : "disactive"}
                  <Input
                    type="hidden"
                    name="is_active"
                    value={isActive ? "true" : "false"}
                  />
                </div>
                <div className="mt-2 flex items-center">
                  <Checkbox
                    checked={isSuperuser}
                    className="mr-3"
                    onCheckedChange={handleIsSuperuserCheckboxChange}
                  />
                  {isSuperuser == true ? "superuser" : "user"}
                  <Input
                    type="hidden"
                    name="is_superuser"
                    value={isSuperuser ? "true" : "false"}
                  />
                </div>
              </div>
            )}
            <div className="flex justify-center">
              <Button className="mt-3 items-center" type="submit">
                SAVE
              </Button>
            </div>
          </Form>
        </CardContent>
        <CardFooter className="flex justify-center">
          {login_user?.is_superuser === true ? (
            <Drawer>
              <DrawerTrigger asChild>
                <Button
                  variant="ghost"
                  className="hover:bg-red-600 hover:text-white"
                >
                  DELETE
                </Button>
              </DrawerTrigger>
              <DrawerContent>
                <div className="mx-auto w-full max-w-sm">
                  <DrawerHeader>
                    <DrawerTitle className="text-2xl text-red-600 text-center">
                      DANGER
                    </DrawerTitle>
                    <DrawerDescription className="text-md text-red-600 text-center">
                      The deletion process cannot be undone.
                      <br />
                      Are you sure you want to delete it?
                    </DrawerDescription>
                  </DrawerHeader>
                  <div className="p-4 pb-0">
                    <div className="flex items-center justify-center space-x-2">
                      <DrawerFooter>
                        <DeleteUserButton user_uuid={user?.uuid} />
                        <DrawerClose asChild>
                          <Button variant="ghost" className="text-black">
                            Cancel
                          </Button>
                        </DrawerClose>
                      </DrawerFooter>
                    </div>
                  </div>
                </div>
              </DrawerContent>
            </Drawer>
          ) : (
            ""
          )}
        </CardFooter>
      </Card>
      <div className="flex flex-col justify-center">
        <Button variant="link" className="text-neutral-500">
          <Link to="/user">Back to User list</Link>
        </Button>
        <Button variant="link" className="text-neutral-500">
          <Link to="/todo">Go to Todo list</Link>
        </Button>
        <Button variant="link" className="text-neutral-500">
          <Link to="/auth/change_password">Change password</Link>
        </Button>
      </div>
    </div>
  );
}
user/delete_user.tsx
user/delete_user.tsx
import { redirect } from "react-router";

// server
import { deleteUser } from "../data/auth.server";

// type
import type { Route } from "./+types/users";

// delete処理をするためだけのページ(実際には何も表示しない)
export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const user_uuid = formData.get("user_uuid");

  if (typeof user_uuid !== "string") {
    return JSON.stringify({ error: "Invalid user ID" });
  }
  try {
    await deleteUser(user_uuid);
    return redirect("/user");
  } catch (error: unknown) {
    return Response.json({ error: "Failed to delete user." });
  }
}

todo処理

todoアプリケーションの中身を共有します。

todo/create.tsx
import { parseFormData, type FileUpload } from "@mjackson/form-data-parser";
import { useState } from "react";
import { Form, redirect } from "react-router";

// server
import { requireAccountSession } from "../data/auth.server";
import { addFiles, createTodo } from "../data/todo.server";

// type
import type { Route } from "./+types/create";
import { type TodoCreate, type ProcessCreate } from "../type/todo";

// ui
import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Input } from "../components/ui/input";
import { Textarea } from "../components/ui/textarea";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";

// dnd-kit
import {
  DndContext,
  closestCenter,
  useSensor,
  useSensors,
  PointerSensor,
  type DragStartEvent,
  type UniqueIdentifier,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";

// components
import SortableProcess from "~/components/component/SortableProcess";
import { DateTimePicker24h } from "~/components/component/DateTimePicker";

import path from "path";
import fs from "fs/promises";

// meta
export function meta({}: Route.MetaArgs) {
  return [
    { title: "Create Todo" },
    {
      property: "og:title",
      content: "content",
    },
    { name: "description", content: "Create new todo." },
  ];
}

export async function loader({ request }: Route.LoaderArgs) {
  const login_user = await requireAccountSession(request);
  if (!login_user) {
    return redirect("/auth/login");
  }
  return login_user;
}

export async function action({ request }: Route.ActionArgs) {
  // データ送信用の配列
  const postFormData = new FormData();
  const temporaryFiles: string[] = [];

  const uploadHandler = async (fileUpload: FileUpload) => {
    if (fileUpload.fieldName === "fileDatas") {
      if (!fileUpload) {
        console.log("No file uploaded. Skipping file upload.");
        return null;
      } else {
        // ファイルを保存するディレクトリを指定
        const uploadDir = path.resolve("./temp");
        // アップロードされたファイルの保存場所を決定
        const filePath = path.join(uploadDir, fileUpload.name);

        // ファイルの内容をバッファとして取得
        const buffer = Buffer.from(await fileUpload.arrayBuffer());

        // ファイルをディスクに書き込む
        try {
          const status = await fs.lstat(filePath);
          if (status.isDirectory()) {
            console.log("File path is a directory. Skipping file upload.");
            return null;
          }
        } catch (err: any) {
          if (err.code !== "ENOENT") {
            throw err;
          }
        }

        // 保存先ディレクトリが存在しない場合は作成
        await fs.mkdir(uploadDir, { recursive: true });
        // ファイルをディスクに書き込む
        await fs.writeFile(filePath, buffer);

        // 一時ファイルを格納
        temporaryFiles.push(filePath);
        // MIMEタイプを取得(必要であれば独自に設定)
        const mimeType = fileUpload.type || "application/octet-stream";

        // Fileオブジェクトを作成して返す
        const file = new File([buffer], fileUpload.name, { type: mimeType });
        return file;
      }
    }
    return null; // 他のフィールドは処理しない
  };

  // 送信用データの取得
  const formData = await parseFormData(
    request,
    uploadHandler, // file upload用
    { maxFileSize: 1024 * 1024 * 10 } // max file size:10MB
  );

  // file upload
  const uploadFiles = formData.getAll("fileDatas") as File[];

  const formDataUpload = new FormData();

  uploadFiles.forEach((file) => {
    formDataUpload.append("files", file); // 同じキー "files" を使用して複数ファイルを追加
  });
  console.log("formDataUpload:", formDataUpload);
  try {
    const fileData = await addFiles(formDataUpload);
    if (!fileData) {
      throw new Error("Failed to submit file data.");
    }
    postFormData.append("fileDatas", JSON.stringify(fileData.file_info));
    console.log("Received fileDatas:", fileData); // for debag
  } catch (error: unknown) {
    console.error("Error detail:", error);
  } finally {
    // 一時ファイル削除
    for (const tempFilePath of temporaryFiles) {
      try {
        await fs.unlink(tempFilePath); // ファイル削除
        console.log(`Temporary file deleted: ${tempFilePath}`);
      } catch (deleteError) {
        console.warn(`Failed to delete tempolary file: ${tempFilePath}`);
      }
    }
  }

  // データ登録処理
  const fields = [
    { key: "title", type: "string" },
    { key: "content", type: "string" },
    { key: "done", type: "boolean" },
    { key: "user_uuid", type: "string" },
    { key: "limit_date", type: "Date" },
  ];

  fields.forEach(({ key, type }) => {
    const value = formData.get(key);

    if (value === null) {
      console.warn(`Key "${key}" not found in formData.`);
      return; // 次のキーに進む
    }

    // datetimepickerのnull処理
    if (key === "limit_date") {
      const rawValue = formData.get(key);
      const value = typeof rawValue === "string" ? rawValue : "";
      const parsedLimitDate =
        value === "" ? "no_date" : new Date(value).toISOString();
      postFormData.append(key, parsedLimitDate ?? "null");
      console.log(
        `Key "${key}" with value "${parsedLimitDate}" appended to postFormData.`
      );
      return;
    }

    if (value !== null) {
      if (type === "boolean") {
        const booleanValue = value === "true";
        postFormData.append(key, booleanValue ? "true" : "false");
      } else if (type === "number") {
        const numberValue = Number(value);
        if (isNaN(numberValue)) {
          console.error(
            `Key "${key}" contains a non-numeric value: "${value}"`
          );
          return;
        }
        postFormData.append(key, numberValue.toString());
      } else {
        postFormData.append(key, value.toString());
      }
    }
    console.log(`Key "${key}" with value "${value}" appended to postFormData.`);
  });

  const processes: ProcessCreate[] = [];
  // processデータの収集
  formData.forEach((value, key) => {
    const matchData = key.match(/^processes-(\w+)-(\d+)$/);
    if (matchData) {
      const [, field, index] = matchData;
      const i = parseInt(index, 10);

      // 配列を初期化して型を明示
      if (!processes[i]) {
        processes[i] = {
          tempId: "",
          title: "",
          content: "",
          done: false,
          limit_date: new Date(),
          order: 0,
        };
      }

      // フィールドに応じたバリデーション
      if (field === "order") {
        processes[i].order = parseInt(value as string, 10);
      } else if (field === "done") {
        processes[i].done = value === "true";
      } else if (field === "tempId") {
        return;
      } else if (field === "limit_date") {
        processes[i].limit_date = value as unknown as Date;
      } else if (field === "title") {
        processes[i].title = value as string;
      } else if (field === "content") {
        processes[i].content = value as string;
      }
    }
  });
  postFormData.append("processes", JSON.stringify(processes));

  try {
    const res = createTodo(request, postFormData);
    if (!res) {
      throw new Error("Failed to submit data.");
    }
    return redirect("/todo");
  } catch (error: unknown) {
    console.error("Error detail:", error);
  }
}

export default function TodoCreate({
  loaderData,
  actionData,
}: Route.ComponentProps) {
  // loader date
  const login_user = loaderData;

  // todo state
  const [isDone, setIsDone] = useState<boolean | undefined>(false);
  const [limitDate, setLimitDate] = useState<Date | undefined>(undefined);

  // process state
  const [processes, setProcesses] = useState<ProcessCreate[]>([]);
  const [processContent, setProcessContent] = useState<string>("");
  const [selectedFiles, setSelectedFiles] = useState<File[]>([]);

  // dnd-kit
  const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
  const sensors = useSensors(useSensor(PointerSensor));

  // dnd-kit ドラッグ開始時のイベントハンドラ
  const hadleDragStart = (event: DragStartEvent): void => {
    setActiveId(event.active.id);
  };

  // dnd-kit ドラッグ終了時のイベントハンドラ
  const handleDragEnd = ({ active, over }: DragEndEvent) => {
    if (over && active.id !== over.id) {
      setProcesses((prev) => {
        const oldIndex = processes.findIndex((p) => p.tempId === active.id);
        const newIndex = processes.findIndex((p) => p.tempId === over.id);
        const newProcesses = arrayMove(prev, oldIndex, newIndex);
        return newProcesses.map((p, index) => ({
          ...p,
          order: index + 1,
        }));
      });
    }
  };

  // add process
  const addProcess = () => {
    const newProcess: ProcessCreate = {
      tempId: String(Date.now()), // 一意のID
      order: processes.length + 1,
      title: "",
      content: "",
      limit_date: null,
      done: false,
    };
    setProcesses((prev) => [...prev, newProcess]);
    setProcessContent((prev) => prev + 1);
  };

  // update process
  const updateProcess = (
    id: UniqueIdentifier,
    field: keyof ProcessCreate,
    value: string
  ) => {
    setProcesses((prev) =>
      prev.map((process) =>
        process.tempId === id ? { ...process, [field]: value } : process
      )
    );
  };

  // remove process
  const removeProcess = (id: UniqueIdentifier) => {
    setProcesses((prev) => prev.filter((process) => process.tempId !== id));
    reassignOrders();
  };

  // renumbering process order
  const reassignOrders = () => {
    setProcesses((prev) =>
      [...prev]
        .filter((process) => process.order !== undefined)
        .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
        .map((process, index) => ({
          ...process,
          order: index + 1,
        }))
    );
  };

  const handleIsDoneCheckboxChange = () => {
    setIsDone((prev) => !prev); // 現在の状態を反転
  };

  const handleDateChange = (date: Date | undefined) => {
    setLimitDate(date || undefined);
  };

  const nonSelectedDate = "1970-01-01T00:00:00Z";

  return (
    <div className="mx-auto m-5">
      <h1 className="text-3xl mb-3 text-center font-bold">Todo Create</h1>
      <div className="flex flex-col justify-center mx-auto max-w-[650px]">
        <Card className="p-3 w-full">
          <Form method="post" encType="multipart/form-data">
            <CardTitle className="flex flex-row mb-2">
              <p className="flex items-center mr-2  min-w-[90px]">Title:</p>
              <Input className="w-full" name="title" placeholder="Todo title" />
            </CardTitle>
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">User ID:</p>
              <Input
                className="w-full"
                name="user_uuid"
                readOnly
                defaultValue={login_user?.uuid}
              />
            </CardContent>
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">Content:</p>
              <Textarea
                className="w-full min-h-[250px]"
                name="content"
                placeholder="Todo Content"
              />
            </CardContent>
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">Limit Date:</p>
              <DateTimePicker24h
                limitDate={limitDate}
                onChange={handleDateChange}
              />
              {/* 隠しフィールドに値を設定 */}
              <Input
                type="hidden"
                name="limit_date"
                value={limitDate ? limitDate.toISOString() : nonSelectedDate}
              />
            </CardContent>
            <CardContent className="flex flex-row mb-2 p-0 items-center">
              <p className="flex items-center mr-2 min-w-[90px]">Status:</p>
              <Checkbox
                checked={isDone}
                className="mr-3"
                onCheckedChange={handleIsDoneCheckboxChange}
              />
              {isDone ? "Done" : "Undone"}
              <Input
                type="hidden"
                name="done"
                value={isDone ? "true" : "false"}
              />
            </CardContent>
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">Processes:</p>
              <div className="flex flex-col w-full">
                <DndContext
                  sensors={sensors}
                  collisionDetection={closestCenter}
                  onDragStart={hadleDragStart}
                  onDragEnd={handleDragEnd}
                >
                  <SortableContext
                    items={processes.map((p) => p.tempId)}
                    strategy={verticalListSortingStrategy}
                  >
                    {processes.length === 0 && (
                      <p className="text-gray-500 w-full">
                        No processes add yet.
                      </p>
                    )}

                    {processes.map((process, index) => (
                      <SortableProcess
                        key={process.tempId}
                        process={process}
                        index={index}
                        updateProcess={updateProcess}
                        removeProcess={removeProcess}
                        reassignOrders={reassignOrders}
                      />
                    ))}
                  </SortableContext>
                </DndContext>
                <Button
                  type="button"
                  onClick={addProcess}
                  className="hover:bg-blue-500 text-white p-2 rounded-md"
                >
                  + Add Process
                </Button>
              </div>
            </CardContent>

            {/* ファイルアップロード */}
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">Files:</p>
              <div className="flex flex-col w-full">
                <Input
                  type="file"
                  name="fileDatas"
                  multiple
                  onChange={(e) => {
                    const files = e.target.files;
                    if (files) {
                      setSelectedFiles(Array.from(files));
                    }
                  }}
                />
                {selectedFiles.length > 0 && (
                  <ul>
                    {selectedFiles.map((file, index) => (
                      <li key={index} className="flex flex-row">
                        <span className="mr-2">{file.name}</span>
                        <span className="mr-2">
                          ({(file.size / 1024).toFixed(2)} KB)
                        </span>
                        <span className="text-gray-500">
                          [{file.type || "unknown"}]
                        </span>
                      </li>
                    ))}
                  </ul>
                )}
              </div>
            </CardContent>

            <Button
              type="submit"
              className="bg-green-500 text-white p-2 mt-4 rounded-md"
            >
              Create Todo
            </Button>
          </Form>
        </Card>
      </div>
    </div>
  );
}

https://zenn.dev/taki/articles/7c317d6612743a

todo/delete.tsx
import { redirect } from "react-router";

// type
import type { Route } from "./+types/todos";

// server
import { deleteTodoData } from "../data/todo.server";

// delete処理をするためだけのページ(実際には何も表示しない)
export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const todo_uuid = formData.get("todo_uuid");

  if (typeof todo_uuid !== "string") {
    return JSON.stringify({ error: "Invalid todo ID" });
  }
  try {
    await deleteTodoData(todo_uuid);
    return redirect("/todo");
  } catch (error: unknown) {
    return Response.json({ error: "Failed to delete todo." });
  }
}
todo/edit.tsx
import { requireAccountSession } from "~/data/auth.server";
import type { Route } from "./+types/edit";
import { Form, redirect } from "react-router";
import { Card, CardContent, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import type {
  EditProcess,
  EditTodo,
  fileData,
  Process,
  ProcessCreate,
} from "~/type/todo";
import type { User } from "~/type/auth";
import { addFiles, editTodo, getEditTodoData } from "~/data/todo.server";
import { useEffect, useState } from "react";
import { Textarea } from "~/components/ui/textarea";
import { Checkbox } from "~/components/ui/checkbox";
import SortableEditProcess from "~/components/component/SortableEditProcess";
import { Button } from "~/components/ui/button";

// dnd-kit
import {
  DndContext,
  closestCenter,
  useSensor,
  useSensors,
  PointerSensor,
  type DragStartEvent,
  type UniqueIdentifier,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import FileDislay from "~/components/component/FileDisplay";
import { parseFormData, type FileUpload } from "@mjackson/form-data-parser";
import path from "path";
import fs from "fs/promises";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { Calendar } from "~/components/ui/calendar";
import { CustomTimePicker } from "~/components/component/CustomTimePicker";
import { date } from "zod";
import { DateTimePicker24h } from "~/components/component/DateTimePicker";

export function meta({ data }: Route.MetaArgs) {
  return [
    { title: `edit ${data?.editTodo.title} | Todo` },
    {
      property: "og:title",
      content: `${data?.editTodo.title}`,
    },
    {
      name: "description",
      content: `Edit todo uuid: ${data?.editTodo.uuid}`,
    },
  ];
}

export async function action({ request, params }: Route.ActionArgs) {
  // データ送信用の配列
  const postFormData = new FormData();
  const temporaryFiles: string[] = [];

  const uploadHandler = async (fileUpload: FileUpload) => {
    console.log(fileUpload);
    if (fileUpload.fieldName === "fileDatas") {
      if (!fileUpload) {
        console.log("No file uploaded. Skipping file upload.");
        return null;
      } else {
        // ファイルを保存するディレクトリを指定
        const uploadDir = path.resolve("./temp");
        // アップロードされたファイルの保存場所を決定
        const filePath = path.join(uploadDir, fileUpload.name);

        // ファイルの内容をバッファとして取得
        const buffer = Buffer.from(await fileUpload.arrayBuffer());

        // ファイルをディスクに書き込む
        try {
          const status = await fs.lstat(filePath);
          if (status.isDirectory()) {
            console.log("File path is a directory. Skipping file upload.");
            return null;
          }
        } catch (err: any) {
          if (err.code !== "ENOENT") {
            throw err;
          }
        }

        // 保存先ディレクトリが存在しない場合は作成
        await fs.mkdir(uploadDir, { recursive: true });
        // ファイルをディスクに書き込む
        await fs.writeFile(filePath, buffer);

        // 一時ファイルを格納
        temporaryFiles.push(filePath);
        // MIMEタイプを取得(必要であれば独自に設定)
        const mimeType = fileUpload.type || "application/octet-stream";

        // Fileオブジェクトを作成して返す
        const file = new File([buffer], fileUpload.name, { type: mimeType });
        return file;
      }
    }
    return null; // 他のフィールドは処理しない
  };

  // 送信用データの取得
  const formData = await parseFormData(
    request,
    uploadHandler, // file upload用
    { maxFileSize: 1024 * 1024 * 10 } // max file size:10MB
  );

  // file upload
  const uploadFiles = formData.getAll("fileDatas") as File[];

  const formDataUpload = new FormData();

  uploadFiles.forEach((file) => {
    formDataUpload.append("files", file); // 同じキー "files" を使用して複数ファイルを追加
  });
  console.log("formDataUpload:", formDataUpload);
  try {
    const fileData = await addFiles(formDataUpload);
    if (!fileData) {
      throw new Error("Failed to submit file data.");
    }
    postFormData.append("fileDatas", JSON.stringify(fileData.file_info));
    console.log("Received fileDatas:", fileData); // デバッグ
  } catch (error: unknown) {
    console.error("Error detail:", error);
  } finally {
    // 一時ファイル削除
    for (const tempFilePath of temporaryFiles) {
      try {
        await fs.unlink(tempFilePath); // ファイル削除
        console.log(`Temporary file deleted: ${tempFilePath}`);
      } catch (deleteError) {
        console.warn(`Failed to delete tempolary file: ${tempFilePath}`);
      }
    }
  }

  // データ登録処理
  const fields = [
    { key: "title", type: "string" },
    { key: "content", type: "string" },
    { key: "done", type: "boolean" },
    { key: "user_uuid", type: "string" },
    { key: "limit_date", type: "Date" },
  ];

  fields.forEach(({ key, type }) => {
    const value = formData.get(key);

    if (value === null) {
      console.warn(`Key "${key}" not found in formData.`);
      return; // 次のキーに進む
    }

    if (value !== null) {
      if (type === "boolean") {
        const booleanValue = value === "true";
        postFormData.append(key, booleanValue ? "true" : "false");
      } else if (type === "number") {
        const numberValue = Number(value);
        if (isNaN(numberValue)) {
          console.error(
            `Key "${key}" contains a non-numeric value: "${value}"`
          );
          return;
        }
        postFormData.append(key, numberValue.toString());
      } else {
        postFormData.append(key, value.toString());
      }
    }
    console.log(`Key "${key}" with value "${value}" appended to postFormData.`);
  });

  const processes: ProcessCreate[] = [];
  // processデータの収集
  formData.forEach((value, key) => {
    const matchData = key.match(/^processes-(\w+)-(\d+)$/);
    if (matchData) {
      const [, field, index] = matchData;
      const i = parseInt(index, 10);

      // 配列を初期化して型を明示
      if (!processes[i]) {
        processes[i] = {
          tempId: "",
          title: "",
          content: "",
          done: false,
          limit_date: new Date(),
          order: 0,
        };
      }

      // validation for field
      if (field === "order") {
        processes[i].order = parseInt(value as string, 10);
      } else if (field === "done") {
        processes[i].done = value === "true";
      } else if (field === "tempId") {
        return;
      } else if (field === "limit_date") {
        processes[i].limit_date = value as unknown as Date;
      } else if (field === "title") {
        processes[i].title = value as string;
      } else if (field === "content") {
        processes[i].content = value as string;
      }
    }
  });
  postFormData.append("processes", JSON.stringify(processes));

  try {
    const res = editTodo(request, params.todo_uuid, postFormData);
    if (!res) {
      throw new Error("Failed to submit data.");
    }
    return redirect("/todo");
  } catch (error: unknown) {
    console.error("Error detail:", error);
  }
}

export async function loader({ params, request }: Route.LoaderArgs) {
  const editTodo = await getEditTodoData(params.todo_uuid);
  const login_user = await requireAccountSession(request);
  if (!login_user) {
    return redirect("/auth/login");
  }
  // tempIdを一時的に既存データに付与
  const processesWithTempId = editTodo.processes.map(
    (process: ProcessCreate) => ({
      ...process,
      tempId:
        process.tempId ||
        String(Date.now()) + Math.random().toString(36).substring(2, 11),
    })
  );
  return {
    editTodo: { ...editTodo, processes: processesWithTempId },
    login_user,
  };
}

export default function TodoEdit({
  loaderData,
}: {
  loaderData: { editTodo: EditTodo; login_user: User };
}) {
  // loader data
  const { editTodo, login_user } = loaderData;

  // todo state
  const [todoTitle, setTodoTitle] = useState<string>(editTodo?.title);
  const [todoContent, setTodoContent] = useState<string>(editTodo?.content);
  const [isDone, setIsDone] = useState<boolean | undefined>(editTodo?.done);
  const [limitDate, setLimitDate] = useState<Date | undefined>(
    editTodo.limit_date
  );
  const [limitDateISOString, setLimitDateISOString] = useState<string | null>(
    ""
  );

  // process state
  const [processes, setProcesses] = useState<EditProcess[]>(editTodo.processes);
  const [processContent, setProcessContent] = useState<string>("");
  const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
  // filedata state
  const [fileDatas, setFileDatas] = useState<fileData[]>(editTodo?.fileDatas);
  const [selectedFiles, setSelectedFiles] = useState<File[]>([]);

  const handleIsDoneCheckboxChange = () => {
    setIsDone((prev) => !prev); // 現在の状態を反転
  };

  // for dnd-kit
  const sensors = useSensors(useSensor(PointerSensor));

  // dnd-kit ドラッグ開始時のイベントハンドラ
  const hadleDragStart = (event: DragStartEvent): void => {
    setActiveId(event.active.id);
  };

  // dnd-kit ドラッグ終了時のイベントハンドラ
  const handleDragEnd = ({ active, over }: DragEndEvent) => {
    if (over && active.id !== over.id) {
      setProcesses((prev) => {
        const oldIndex = processes?.findIndex((p) => p.tempId === active.id);
        const newIndex = processes?.findIndex((p) => p.tempId === over.id);
        const newProcesses = arrayMove(prev, oldIndex, newIndex);
        return newProcesses.map((p, index) => ({
          ...p,
          order: index + 1,
        }));
      });
    }
  };

  const addProcess = () => {
    const editProcess: EditProcess = {
      tempId: String(Date.now()), // 一意のID
      uuid: "",
      order: processes.length + 1,
      title: "",
      content: "",
      done: false,
      limit_date: new Date(Date.now()),
    };
    setProcesses((prev) => [...prev, editProcess]);
    setProcessContent((prev) => prev + 1);
  };

  const updateProcess = (
    id: UniqueIdentifier,
    field: keyof EditProcess,
    value: string | boolean | undefined
  ) => {
    setProcesses((prev) =>
      prev.map((process) =>
        process.tempId === id ? { ...process, [field]: value } : process
      )
    );
  };

  const removeProcess = (id: UniqueIdentifier) => {
    setProcesses((prev) => prev.filter((process) => process.tempId !== id));
    reassignOrders();
  };

  const reassignOrders = () => {
    setProcesses((prev) =>
      [...prev]
        .filter((process) => process.order !== undefined)
        .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
        .map((process, index) => ({
          ...process,
          order: index + 1,
        }))
    );
  };

  const handleDateChange = (date: Date) => {
    setLimitDate(date);
    setLimitDateISOString(date ? date.toISOString() : null);
  };

  // 初期値が変更されたときにリセット
  useEffect(() => {
    if (editTodo?.limit_date) {
      const parsedDate = new Date(editTodo?.limit_date);
      setLimitDate(parsedDate);
      setLimitDateISOString(parsedDate.toISOString());
    }
  }, [editTodo?.limit_date]);

  return (
    <div className="mx-auto m-5">
      <h1 className="text-3xl mb-3 text-center font-bold">Edit Todo</h1>
      <div className="flex justify-center text-[12px] mb-2">
        <p>Edit todo ID: {editTodo?.uuid} </p>
      </div>
      <div className="flex flex-col justify-center mx-auto max-w-[650px]">
        <Card className="p-3 w-full">
          <Form method="post" encType="multipart/form-data">
            <CardTitle className="flex flex-row mb-2">
              <p className="flex items-center mr-2  min-w-[90px]">Title:</p>
              <Input
                className="w-full"
                name="title"
                placeholder="Todo title"
                value={todoTitle}
                onChange={(e) => setTodoTitle(e.target.value)}
              />
            </CardTitle>
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">User ID:</p>
              <Input
                className="w-full"
                name="user_uuid"
                readOnly
                defaultValue={login_user?.uuid}
              />
            </CardContent>
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">Content:</p>
              <Textarea
                className="w-full min-h-[250px]"
                name="content"
                placeholder="Todo Content"
                value={todoContent}
                onChange={(e) => setTodoContent(e.target.value)}
              />
            </CardContent>
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">Limit Date:</p>
              <DateTimePicker24h
                limitDate={limitDate || undefined} // カレンダーの初期値
                onChange={handleDateChange} // 選択された日付を受け取る
              />
              {/* 隠しフィールドに値を設定 */}
              <Input
                type="hidden"
                name="limit_date"
                value={limitDateISOString || ""}
              />
            </CardContent>

            <CardContent className="flex flex-row mb-2 p-0 items-center">
              <p className="flex items-center mr-2 min-w-[90px]">Status:</p>
              <Checkbox
                checked={isDone}
                className="mr-3"
                onCheckedChange={handleIsDoneCheckboxChange}
              />
              <p>{isDone ? "Done" : "Undone"}</p>
              <p>{editTodo?.done}</p>
              <Input
                type="hidden"
                name="done"
                value={isDone ? "true" : "false"}
              />
            </CardContent>
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">Processes:</p>
              <div className="flex flex-col w-full">
                <DndContext
                  sensors={sensors}
                  collisionDetection={closestCenter}
                  onDragStart={hadleDragStart}
                  onDragEnd={handleDragEnd}
                >
                  <SortableContext
                    items={processes?.map((p) => p.tempId)}
                    strategy={verticalListSortingStrategy}
                  >
                    {processes?.length === 0 && (
                      <p className="text-gray-500 w-full">
                        No processes add yet.
                      </p>
                    )}

                    {processes?.map((process, index) => (
                      <SortableEditProcess
                        key={process.tempId}
                        process={process}
                        index={index}
                        updateProcess={updateProcess}
                        removeProcess={removeProcess}
                        reassignOrders={reassignOrders}
                      />
                    ))}
                  </SortableContext>
                </DndContext>

                <Button
                  type="button"
                  onClick={addProcess}
                  className="hover:bg-blue-500 text-white p-2 rounded-md"
                >
                  + Add Process
                </Button>
              </div>
            </CardContent>
            <CardContent className="flex flex-row mb-2 p-0">
              <p className="flex items-center mr-2 min-w-[90px]">Files:</p>
              <div className="flex flex-col w-full">
                <p className="flex items-center mr-2 min-w-[90px]">
                  Already Uploaded Files:
                </p>
                {fileDatas.length > 0 && (
                  <ul>
                    {fileDatas.map((file, index) => (
                      <li key={index} className="flex flex-row mb-1">
                        <FileDislay file={file} />
                      </li>
                    ))}
                  </ul>
                )}

                <Input
                  type="file"
                  name="fileDatas"
                  multiple
                  onChange={(e) => {
                    const files = e.target.files;
                    if (files) {
                      setSelectedFiles(Array.from(files));
                    }
                  }}
                />
                <p className="text-[10px] text-red-400 mt-1 ml-1">
                  *既存ファイルを残したい場合は再度アップロードが必要です
                </p>
              </div>
            </CardContent>
            <Button
              type="submit"
              className="bg-green-500 text-white p-2 mt-4 rounded-md"
            >
              Edit Todo
            </Button>
          </Form>
        </Card>
      </div>
    </div>
  );
}
todo/layout.tsx
import { Outlet } from "react-router";

export default function TodoLayout() {
  return (
    <div className="mt-3">
      <Outlet />
    </div>
  );
}
todo/todo_uuid.tsx
import { useState } from "react";
import { Link } from "react-router";

// type
import type { Route } from "./+types/todo_uuid";
import { type Todo } from "../type/todo";
import type { User } from "../type/auth";

// ui
import { Button } from "~/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "../components/ui/card";
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "~/components/ui/drawer";
import { Checkbox } from "~/components/ui/checkbox";

// server
import { getTodoData } from "~/data/todo.server";
import { requireAccountSession } from "../data/auth.server";

// dnd-kit
import { AnimatePresence, motion } from "motion/react";

// components
import DeleteTodoButton from "../components/component/DeleteTodoButton";
import FileDislay from "~/components/component/FileDisplay";

// date
import customDateFormat from "~/data/date";

// meta
export function meta({ data }: Route.MetaArgs) {
  return [
    { title: `${data?.todo.title} | Todo` },
    {
      property: "og:title",
      content: `${data?.todo.title}`,
    },
    {
      name: "description",
      content: `Todo uuid: ${data?.todo.uuid}`,
    },
  ];
}

export async function loader({ params, request }: Route.LoaderArgs) {
  const todo = await getTodoData(params.todo_uuid);
  const login_user = await requireAccountSession(request);
  return { todo, login_user };
}

export default function TodoDetail_uuid({
  loaderData,
}: {
  loaderData: { todo: Todo; login_user: User };
}) {
  // loader data
  const { todo, login_user } = loaderData;

  // state
  const [isDone, setIsDone] = useState<boolean | undefined>(todo?.done);
  const [extendedId, setExtendedId] = useState<number | null>(null);
  const toggleExpand = (id: number) => {
    setExtendedId(extendedId === todo?.id ? null : todo?.id);
  };

  // 表示切り替え
  const [displayText, setDisplayText] = useState<boolean>(true);
  const toggleDisplayTextChange = () => {
    setDisplayText(!displayText);
  };

  const nonSelectedDate = "1970-01-01T09:00:00";

  return (
    <div className="mx-auto m-5 max-w-[650px]">
      <h1 className="text-3xl mb-3 text-center font-bold">Todo Detail</h1>
      <div className="flex justify-center">
        <Button asChild variant="link">
          <Link to="/todo">Back to Todo list.</Link>
        </Button>
      </div>
      <div className="container mb-2">
        <div className="flex justify-end">
          <Button
            variant="ghost"
            asChild
            className="hover:bg-green-300 hover:text-gray-700 mr-2"
          >
            <Link to={`/todo/edit/${todo.uuid}`}>EDIT</Link>
          </Button>
          {login_user.is_superuser || todo.user_uuid === login_user.uuid ? (
            <Drawer>
              <DrawerTrigger asChild>
                <Button
                  variant="ghost"
                  className="hover:bg-red-600 hover:text-white mr-2"
                >
                  DELETE
                </Button>
              </DrawerTrigger>
              <DrawerContent>
                <div className="mx-auto w-full max-w-sm">
                  <DrawerHeader>
                    <DrawerTitle className="text-2xl text-red-600 text-center">
                      DANGER
                    </DrawerTitle>
                    <DrawerDescription className="text-md text-red-600 text-center">
                      削除の処理は取り消すことはできません。
                      <br />
                      本当に削除してよいですか?
                    </DrawerDescription>
                  </DrawerHeader>
                  <div className="p-4 pb-0">
                    <div className="flex items-center justify-center space-x-2">
                      <DrawerFooter>
                        <DeleteTodoButton todo_uuid={todo.uuid} />
                        <DrawerClose asChild>
                          <Button variant="ghost" className="text-black">
                            Cancel
                          </Button>
                        </DrawerClose>
                      </DrawerFooter>
                    </div>
                  </div>
                </div>
              </DrawerContent>
            </Drawer>
          ) : (
            <></>
          )}
        </div>
      </div>

      <div className="flex flex-col justify-center mx-auto max-w-[650px]">
        <Card className="p-3 w-full" onClick={() => toggleExpand(todo.id)}>
          <CardTitle>
            <p className="text-2xl font-bold">{todo?.title}</p>
          </CardTitle>
          <CardDescription>
            <p>ID: {todo?.uuid}</p>
          </CardDescription>
          <div className="flex justify-start items-center">
            <Checkbox checked={isDone} className="mr-3" />
            <p>{isDone == true ? "Done!" : "Undone"}</p>
            <p className="ml-2 text-red-400">
              {todo?.limit_date.toString() === nonSelectedDate ? (
                <></>
              ) : (
                <p>
                  LIMIT:
                  {customDateFormat(
                    new Date(todo?.limit_date),
                    "yyyy-MM-dd HH:mm"
                  )}
                </p>
              )}
            </p>
          </div>
          <CardContent className="mt-3 p-0 whitespace-break-spaces">
            {todo?.content}
          </CardContent>

          {(todo?.processes?.length || 0) > 0 ||
          (todo?.fileDatas?.length || 0) > 0 ? (
            <div className="flex justify-end">
              <CardDescription
                className="cursor-pointer"
                onClick={toggleDisplayTextChange}
              >
                {displayText
                  ? "▼ Check processes and files."
                  : "▲ Close processes and files."}
              </CardDescription>
            </div>
          ) : (
            <></>
          )}
        </Card>
        {/* 参考URL:https://motion.dev/ */}
        {/* Cardの中に配置すると、Cardも伸縮する */}
        <AnimatePresence>
          {extendedId === todo?.id && (
            // 縦方向の展開
            <motion.div
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: "auto", width: "auto", opacity: 1 }}
              exit={{ height: 0, opacity: 0 }}
              transition={{ duration: 0.3 }}
              className="overflow-hidden"
            >
              {/* 横方向の展開 */}
              <motion.div
                initial={{ x: -20, opacity: 0 }}
                animate={{ x: 0, opacity: 1 }}
                transition={{
                  delay: 0.3, // Delay to synchronize with Y-axis expansion
                  duration: 0.5,
                }}
                className="rounded-lg mt-3"
              >
                <CardHeader className="mx-2 p-0">【Process】</CardHeader>
                <CardContent className="p-0 h-auto">
                  <div className="space-2">
                    {todo?.processes?.map((process, index) => (
                      <motion.div
                        key={index}
                        initial={{ opacity: 0, y: -20 }}
                        animate={{ opacity: 1, x: 0, y: 0 }}
                        transition={{ delay: 0.1 * index }}
                        className="bg-white p-2 border m-2 rounded-lg h-hull whitespace-nowrap"
                      >
                        <p>Process {process?.order}</p>
                        <p className="text-md font-bold">{process?.title}</p>
                        <p className="whitespace-break-spaces">
                          {process?.content}
                        </p>
                        <div className="flex justify-start items-center">
                          <Checkbox checked={process.done} className="mr-3" />
                          {process?.done == true ? "Done!" : "Undone"}
                        </div>
                        <p className="ml-2 text-red-400">
                          {todo?.limit_date.toString() === nonSelectedDate ? (
                            <></>
                          ) : (
                            <p>
                              LIMIT:
                              {customDateFormat(
                                new Date(process?.limit_date),
                                "yyyy-MM-dd HH:mm"
                              )}
                            </p>
                          )}
                        </p>
                      </motion.div>
                    ))}
                  </div>
                </CardContent>
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>
        <AnimatePresence>
          {extendedId === todo?.id && (
            <motion.div
              initial={{ height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={{ height: 0, opacity: 0 }}
              transition={{ duration: 0.3 }}
              className="overflow-hidden"
            >
              <motion.div
                initial={{ x: -20, opacity: 0 }}
                animate={{ x: 0, opacity: 1 }}
                transition={{
                  delay: 0.5, // Delay to synchronize with Y-axis expansion
                  duration: 0.5,
                }}
                className="rounded-lg mt-3"
              >
                <CardHeader className="mx-2 p-0">【Files】</CardHeader>
                <CardContent className="p-0 h-auto">
                  <div className="space-2">
                    {todo?.fileDatas?.map((file, index) => (
                      <motion.div
                        key={index}
                        initial={{ opacity: 0, y: -20 }}
                        animate={{ opacity: 1, x: 0, y: 0 }}
                        transition={{ delay: 0.1 * index }}
                        className="bg-white p-2 border m-2 rounded-lg h-hull whitespace-nowrap"
                      >
                        <FileDislay file={file} />
                      </motion.div>
                    ))}
                  </div>
                </CardContent>
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}
todo/todos.tsx
import { useMemo, useState } from "react";
import { Link, redirect } from "react-router";

// icons
import {
  ClockArrowDown,
  ClockArrowUp,
  CircleOff,
  CircleCheck,
  AlignJustify,
} from "lucide-react";

// ui
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardTitle,
} from "~/components/ui/card";

// server
import customDateFormat from "~/data/date";
import {
  getTokenFromSession,
  requireAccountSession,
} from "../data/auth.server";
import { getAllTodoData, getFilteredTodos } from "../data/todo.server";

// type
import type { Route } from "./+types/todos";
import type { Todo } from "../type/todo";
import type { User } from "../type/auth";

// components
import SearchForm from "../components/component/SearchForm";

// meta
export function meta({}: Route.MetaArgs) {
  return [
    { title: "All Todos" },
    {
      property: "og:title",
      content: "content",
    },
    { name: "description", content: "All todos are displayed." },
  ];
}

export async function loader({ request }: Route.LoaderArgs) {
  const token = await getTokenFromSession(request);
  const login_user = await requireAccountSession(request);

  if (token && login_user?.is_superuser === true) {
    try {
      const todos = await getAllTodoData(token?.access_token);
      return { todos, login_user };
    } catch (error: unknown) {
      console.error("Failed to fetch todos:", error);
      return { todos: [] };
    }
  }
  if (!login_user) {
    return redirect("/auth/login");
  }
  if (!token || !login_user) {
    return { todos: [] };
  }
  try {
    const todos = await getFilteredTodos(token?.access_token, login_user?.uuid);
    return { todos, login_user };
  } catch (error: unknown) {
    console.error("Failed to fetch todos:", error);
    return { todos: [] };
  }
}

export default function Todos({
  loaderData,
}: {
  loaderData: { todos: Todo[]; login_user: User };
}) {
  // loader data
  const { todos, login_user } = loaderData;

  // state
  const [filterStatus, setFilterStatus] = useState<"all" | "done" | "undone">(
    "all"
  );
  const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
  const [searchQuery, setSearchQuery] = useState<string>("");

  // フィルタリング + 並び替え
  const processedTodos = useMemo(() => {
    let filtered = todos;

    // filtering
    if (filterStatus === "done") {
      filtered = todos.filter((todo) => todo.done === true);
    } else if (filterStatus === "undone") {
      filtered = todos.filter((todo) => todo.done === false);
    }

    // search
    if (searchQuery) {
      filtered = filtered.filter(
        (todo) =>
          todo.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
          (todo.content &&
            todo.content.toLowerCase().includes(searchQuery.toLowerCase()))
      );
    }

    // sorting
    return filtered.sort((a, b) => {
      if (sortOrder === "asc") {
        return (
          new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
        );
      } else {
        return (
          new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
        );
      }
    });
  }, [todos, filterStatus, sortOrder, searchQuery]);

  // search handler for realtime
  const handleSearch = (query: string) => {
    setSearchQuery(query);
  };

  // date format
  const formatted_now = customDateFormat(new Date(), "yyyy-MM-dd HH:mm:ss");

  const nonSelectedDate = "1970-01-01T09:00:00";

  return (
    <div className="lg:m-12 md:m-10 sm:m-3">
      <h1 className="flex justify-center text-3xl font-bold mb-3">
        My Todo List
      </h1>

      <SearchForm onSearch={handleSearch} login_user={login_user} />

      {/* 絞り込み条件 */}
      <div className="flex gap-2 m-4 justify-center">
        <Button
          variant={filterStatus === "all" ? "default" : "outline"}
          onClick={() => setFilterStatus("all")}
        >
          <span>
            <AlignJustify />
          </span>
          All
        </Button>
        <Button
          variant={filterStatus === "done" ? "default" : "outline"}
          onClick={() => setFilterStatus("done")}
        >
          <span>
            <CircleCheck />
          </span>
          Done
        </Button>
        <Button
          variant={filterStatus === "undone" ? "default" : "outline"}
          onClick={() => setFilterStatus("undone")}
        >
          <span>
            <CircleOff />
          </span>
          Undone
        </Button>
      </div>

      {/* 並び替え条件 */}
      <div className="flex gap-2 m-4 justify-center">
        <Button
          variant={sortOrder === "asc" ? "default" : "outline"}
          onClick={() => setSortOrder("asc")}
        >
          <span>
            <ClockArrowDown />
          </span>
          Ascending
        </Button>
        <Button
          variant={sortOrder === "desc" ? "default" : "outline"}
          onClick={() => setSortOrder("desc")}
        >
          <span>
            <ClockArrowUp />
          </span>
          Descending
        </Button>
      </div>

      <div className="flex justify-center">
        <Button asChild variant="link">
          <Link to="/">Back to Top</Link>
        </Button>
      </div>

      <div className="container max-w-[650px] mx-auto">
        Total
        {processedTodos.length == 1 ? (
          <span className="ml-2">{processedTodos.length} todo</span>
        ) : (
          <span className="ml-2">{processedTodos.length} todos</span>
        )}
      </div>

      <div className="container mx-auto grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2 auto-rows-auto">
        {processedTodos?.length > 0 ? (
          processedTodos?.map((todo) => (
            <div className="m-2">
              <Link to={`/todo/${todo.uuid}`}>
                <Card className="min-w-[250px] min-h-[220px] p-3 hover:transform hover:duration-150 hover:scale-105 ">
                  <CardTitle className="text-xl m-2 p-0">
                    {todo.title}
                  </CardTitle>
                  <CardDescription className="m-2 p-0 flex flex-row text-[12px]">
                    <Checkbox checked={todo?.done} className="mr-3" />
                    <p>{todo?.done == true ? "Done!" : "Undone"}</p>
                  </CardDescription>
                  <CardContent className="text-[14px] m-2 p-0 line-clamp-3">
                    {todo.content}
                  </CardContent>
                  <CardContent className="m-2 p-0 flex flex-row text-[12px]">
                    <p className="mr-2">Limit Date</p>
                    {todo?.limit_date.toString() === nonSelectedDate ? (
                      <p>No Limit</p>
                    ) : (
                      <p className="text-red-500">
                        {customDateFormat(
                          new Date(todo?.limit_date),
                          "yyyy-MM-dd HH:mm"
                        )}
                      </p>
                    )}
                  </CardContent>
                  <CardFooter className="text-[10px] m-2 p-0">
                    Created at:{" "}
                    {customDateFormat(
                      new Date(todo?.created_at),
                      "yyyy-MM-dd HH:mm"
                    )}
                  </CardFooter>
                </Card>
              </Link>
            </div>
          ))
        ) : (
          <>No todo data.</>
        )}
      </div>
      <div className="flex justify-center mt-2 text-sm text-gray-500">
        A list of at {formatted_now}
      </div>
    </div>
  );
}

https://dndkit.com/

最後に

  • TypeScriptを使用して基本的に型定義していますが、不十分な部分やanyを使っている部分もあり、まだまだ勉強不足ですので、あらかじめご了承ください
  • ChatGPTと二人三脚で作ったものですので、多少の間違いはあるかと思います
  • 詳しいロジックなどは解説していません(自分より詳しく解説できる(もしくはしている)ものがあると思いますので...)
  • サーバーサイドは、FastAPIで書きました(だんだん慣れてきました)

P.S

今後は時間を見ながら、dnd-kitのことも勉強がてら、記事にできればと思っています。
dnd-kitはChatGPTをベースに改良しているものもありますので、不完全かもしれません。
まだまだ勉強ですね...。

Discussion