🔃
react router v7に入門してみた
react router v7に入門してみた
私は、remixを使って色々遊んでいましたが、今回のremixとreact router v7の統合を機に、reacct router v7に乗り換えてみました。
試しに、todoアプリケーションを作ってみたので、共有したいと思います。
バックエンドの参考記事はこちら
アプリケーションの立ち上げ
アプリケーションの立ち上げは、公式ドキュメントに従って行います。
なお、UIにはshadcnを使いました。react19を使っていますが、特に問題なく動いています。
ルーティング
アプリケーションが立ち上がると、色々ディレクトリやファイルが生成されますが、まずはルーティングの設定を確認します。
基本的な説明は、以下の公式ドキュメントを確認して下さい。
また、ここからは、私が作った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処理
ここでは今回のコードを示します。
基本的には前回の処理とほとんど同じなので、こちらの記事を参照ください。
⚪︎フロントエンド
⚪︎バックエンド
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>
);
}
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>
);
}
最後に
- TypeScriptを使用して基本的に型定義していますが、不十分な部分やanyを使っている部分もあり、まだまだ勉強不足ですので、あらかじめご了承ください
- ChatGPTと二人三脚で作ったものですので、多少の間違いはあるかと思います
- 詳しいロジックなどは解説していません(自分より詳しく解説できる(もしくはしている)ものがあると思いますので...)
- サーバーサイドは、FastAPIで書きました(だんだん慣れてきました)
P.S
今後は時間を見ながら、dnd-kitのことも勉強がてら、記事にできればと思っています。
dnd-kitはChatGPTをベースに改良しているものもありますので、不完全かもしれません。
まだまだ勉強ですね...。
Discussion