Repository: saitamau-maximum/id
Pull request #148: feat: event editor
Author: login: sor4chi
Created: 2025-03-17T07:01:06Z | Merged: 2025-03-19T13:22:30Z

まだまだだけどとりあえずeditorだけ作りました
resolve #89

![image](https://github.com/user-attachments/assets/9bad0f4f-8d53-43f1-815f-05fe3dc292d9)


Linked issue #89: bio 11 行入力可能
設定画面表示 10 行入力可能、実際 11 行入力可能。修正求。簡単。

Diff:
diff --git a/workspaces/client/app/components/ui/icon-button.tsx b/workspaces/client/app/components/ui/icon-button.tsx
index 1f8dd3c1..14645f50 100644
--- a/workspaces/client/app/components/ui/icon-button.tsx
+++ b/workspaces/client/app/components/ui/icon-button.tsx
@@ -72,6 +72,7 @@ export const IconButton = ({
 	<button
 		className={iconButtonStyle({ variant, color, disabled })}
 		aria-label={label}
+		title={label} // Add title attribute for tooltip
 		disabled={disabled}
 		{...props}
 	>
diff --git a/workspaces/client/app/constant.ts b/workspaces/client/app/constant.ts
index ecfa85db..f788fd1b 100644
--- a/workspaces/client/app/constant.ts
+++ b/workspaces/client/app/constant.ts
@@ -6,9 +6,6 @@ export const GRADE = [
 	{ label: "博士 (Doctor)", identifier: ["D1", "D2", "D3"] },
 ];
 
-export const BIO_MAX_LENGTH = 255; // Bioは255文字まで許容
-export const BIO_MAX_NEWLINE = 10; // Bioは10行まで許容
-
 export const RESERVED_WORDS = [
 	"maximum",
 	"home",
diff --git a/workspaces/client/app/repository/calendar.ts b/workspaces/client/app/repository/calendar.ts
index 1eb0b55b..d80e31b2 100644
--- a/workspaces/client/app/repository/calendar.ts
+++ b/workspaces/client/app/repository/calendar.ts
@@ -1,18 +1,10 @@
 import type { CalendarEvent } from "~/types/event";
 import { client } from "~/utils/hono";
 
-export interface ICreateEventPayload {
-	userId: string;
-	title: string;
-	description: string;
-	startAt: string;
-	endAt: string;
-}
-
 export interface ICalendarRepository {
 	getAllEvents: () => Promise<CalendarEvent[]>;
 	getAllEvents$$key: () => unknown[];
-	createEvent: (event: ICreateEventPayload) => Promise<void>;
+	createEvent: (event: Omit<CalendarEvent, "id" | "userId">) => Promise<void>;
 	updateEvent: (event: CalendarEvent) => Promise<void>;
 	deleteEvent: (eventId: CalendarEvent["id"]) => Promise<void>;
 }
@@ -34,9 +26,13 @@ export class CalendarRepositoryImpl implements ICalendarRepository {
 		return ["calendar-events"];
 	}
 
-	async createEvent(event: ICreateEventPayload) {
+	async createEvent(event: Omit<CalendarEvent, "id" | "userId">) {
 		const res = await client.calendar.events.$post({
-			json: event,
+			json: {
+				...event,
+				startAt: event.startAt.toISOString(),
+				endAt: event.endAt.toISOString(),
+			},
 		});
 		if (!res.ok) {
 			throw new Error("Failed to create event");
diff --git a/workspaces/client/app/routes.ts b/workspaces/client/app/routes.ts
index a124aa76..2427decb 100644
--- a/workspaces/client/app/routes.ts
+++ b/workspaces/client/app/routes.ts
@@ -31,6 +31,9 @@ export default [
 								),
 							]
 						: []),
+					...(FLAG.ENABLE_CALENDAR
+						? [route("events", "routes/dashboard/admin/events/page.tsx")]
+						: []),
 				]),
 			]),
 			...(FLAG.ENABLE_OAUTH_REGISTRATION
diff --git a/workspaces/client/app/routes/dashboard/admin/events/internal/components/callable-create-event-dialog.tsx b/workspaces/client/app/routes/dashboard/admin/events/internal/components/callable-create-event-dialog.tsx
new file mode 100644
index 00000000..fa2a1212
--- /dev/null
+++ b/workspaces/client/app/routes/dashboard/admin/events/internal/components/callable-create-event-dialog.tsx
@@ -0,0 +1,130 @@
+import { valibotResolver } from "@hookform/resolvers/valibot";
+import { createCallable } from "react-call";
+import { useForm } from "react-hook-form";
+import { css } from "styled-system/css";
+import * as v from "valibot";
+import { ButtonLike } from "~/components/ui/button-like";
+import { Dialog } from "~/components/ui/dialog";
+import { Form } from "~/components/ui/form";
+import { ErrorDisplay } from "~/components/ui/form/error-display";
+import { EVENT_DESCRIPTION_MAX_LINES, EventSchemas } from "~/schema/event";
+import type { CalendarEvent } from "~/types/event";
+
+type Payload =
+	| {
+			type: "success";
+			payload: Omit<CalendarEvent, "id" | "userId">;
+	  }
+	| {
+			type: "dismiss";
+	  };
+
+const CreateFormSchema = v.object({
+	title: EventSchemas.Title,
+	description: EventSchemas.Description,
+	startAt: EventSchemas.StartAt,
+	endAt: EventSchemas.EndAt,
+});
+
+type CreateFormValues = v.InferInput<typeof CreateFormSchema>;
+
+export const CreateEventDialog = createCallable<void, Payload>(({ call }) => {
+	const {
+		handleSubmit,
+		register,
+		formState: { errors },
+	} = useForm<CreateFormValues>({
+		resolver: valibotResolver(CreateFormSchema),
+	});
+
+	const onSubmit = async (values: CreateFormValues) => {
+		call.end({
+			type: "success",
+			payload: {
+				...values,
+				startAt: new Date(values.startAt),
+				endAt: new Date(values.endAt),
+			},
+		});
+	};
+
+	return (
+		<Dialog
+			title="イベントを作成"
+			isOpen
+			isDismissable
+			onOpenChange={(isOpen) => {
+				if (!isOpen) call.end({ type: "dismiss" });
+			}}
+		>
+			<form
+				onSubmit={handleSubmit(onSubmit)}
+				className={css({
+					display: "grid",
+					gap: 4,
+					gridTemplateColumns: "1fr",
+				})}
+			>
+				<Form.Field.TextInput
+					label="タイトル"
+					required
+					error={errors.title?.message}
+					{...register("title")}
+				/>
+
+				<Form.Field.TextArea
+					label="説明"
+					required
+					rows={EVENT_DESCRIPTION_MAX_LINES}
+					error={errors.description?.message}
+					{...register("description")}
+				/>
+
+				<Form.Field.WithLabel label="開始日時" required>
+					{(id) => (
+						<>
+							<Form.Input
+								id={id}
+								required
+								type="datetime-local"
+								{...register("startAt")}
+							/>
+							<ErrorDisplay error={errors.startAt?.message} />
+						</>
+					)}
+				</Form.Field.WithLabel>
+
+				<Form.Field.WithLabel label="終了日時" required>
+					{(id) => (
+						<>
+							<Form.Input
+								id={id}
+								required
+								type="datetime-local"
+								{...register("endAt")}
+							/>
+							<ErrorDisplay error={errors.endAt?.message} />
+						</>
+					)}
+				</Form.Field.WithLabel>
+
+				<div
+					className={css({
+						display: "flex",
+						justifyContent: "center",
+						gap: 4,
+						gridColumn: "1 / -1",
+						marginTop: 4,
+					})}
+				>
+					<button type="button" onClick={() => call.end({ type: "dismiss" })}>
+						<ButtonLike variant="secondary">キャンセル</ButtonLike>
+					</button>
+					<button type="submit">
+						<ButtonLike variant="primary">作成</ButtonLike>
+					</button>
+				</div>
+			</form>
+		</Dialog>
+	);
+});
diff --git a/workspaces/client/app/routes/dashboard/admin/events/internal/components/callable-edit-event-dialog.tsx b/workspaces/client/app/routes/dashboard/admin/events/internal/components/callable-edit-event-dialog.tsx
new file mode 100644
index 00000000..5a051fbd
--- /dev/null
+++ b/workspaces/client/app/routes/dashboard/admin/events/internal/components/callable-edit-event-dialog.tsx
@@ -0,0 +1,152 @@
+import { valibotResolver } from "@hookform/resolvers/valibot";
+import { createCallable } from "react-call";
+import { useForm } from "react-hook-form";
+import { css } from "styled-system/css";
+import * as v from "valibot";
+import { ButtonLike } from "~/components/ui/button-like";
+import { Dialog } from "~/components/ui/dialog";
+import { Form } from "~/components/ui/form";
+import { ErrorDisplay } from "~/components/ui/form/error-display";
+import { EVENT_DESCRIPTION_MAX_LINES, EventSchemas } from "~/schema/event";
+import type { CalendarEvent } from "~/types/event";
+
+interface Props {
+	event: CalendarEvent;
+}
+
+type Payload =
+	| {
+			type: "success";
+			payload: CalendarEvent;
+	  }
+	| {
+			type: "dismiss";
+	  };
+
+const UpdateFormSchema = v.object({
+	title: EventSchemas.Title,
+	description: EventSchemas.Description,
+	startAt: EventSchemas.StartAt,
+	endAt: EventSchemas.EndAt,
+});
+
+type UpdateFormValues = v.InferInput<typeof UpdateFormSchema>;
+
+// HTMLの`datetime-local`のフォーマットは`YYYY-MM-DDTHH:mm`なので、タイムゾーンが含まれているとdefaultValueが機能しない
+// スウェーデンのタイムゾーンでフォーマットがそれなので`sv-SE`を指定
+const formatHTMLDate = (date: Date) =>
+	date.toLocaleTimeString("sv-SE", {
+		year: "numeric",
+		month: "2-digit",
+		day: "2-digit",
+		hour: "2-digit",
+		minute: "2-digit",
+	});
+
+export const EditEventDialog = createCallable<Props, Payload>(
+	({ call, event }) => {
+		const {
+			handleSubmit,
+			register,
+			formState: { errors },
+		} = useForm<UpdateFormValues>({
+			resolver: valibotResolver(UpdateFormSchema),
+			defaultValues: {
+				title: event.title,
+				description: event.description,
+				startAt: formatHTMLDate(event.startAt),
+				endAt: formatHTMLDate(event.endAt),
+			},
+		});
+
+		const onSubmit = async (values: UpdateFormValues) => {
+			const updatedEvent: CalendarEvent = {
+				...event,
+				...values,
+				startAt: new Date(values.startAt),
+				endAt: new Date(values.endAt),
+			};
+			call.end({ type: "success", payload: updatedEvent });
+		};
+
+		return (
+			<Dialog
+				title="イベントを編集"
+				isOpen
+				isDismissable
+				onOpenChange={(isOpen) => {
+					if (!isOpen) call.end({ type: "dismiss" });
+				}}
+			>
+				<form
+					onSubmit={handleSubmit(onSubmit)}
+					className={css({
+						display: "grid",
+						gap: 4,
+						gridTemplateColumns: "1fr",
+					})}
+				>
+					<Form.Field.TextInput
+						label="タイトル"
+						required
+						error={errors.title?.message}
+						{...register("title")}
+					/>
+
+					<Form.Field.TextArea
+						label="説明"
+						required
+						rows={EVENT_DESCRIPTION_MAX_LINES}
+						error={errors.description?.message}
+						{...register("description")}
+					/>
+
+					<Form.Field.WithLabel label="開始日時" required>
+						{(id) => (
+							<>
+								<Form.Input
+									id={id}
+									required
+									type="datetime-local"
+									{...register("startAt")}
+								/>
+								<ErrorDisplay error={errors.startAt?.message} />
+							</>
+						)}
+					</Form.Field.WithLabel>
+
+					<Form.Field.WithLabel label="終了日時" required>
+						{(id) => (
+							<>
+								<Form.Input
+									id={id}
+									required
+									type="datetime-local"
+									{...register("endAt")}
+								/>
+								<ErrorDisplay error={errors.endAt?.message} />
+							</>
+						)}
+					</Form.Field.WithLabel>
+
+					<div
+						className={css({
+							display: "flex",
+							justifyContent: "center",
+							gap: 4,
+							gridColumn: "1 / -1",
+							marginTop: 4,
+						})}
+					>
+						<button type="button" onClick={() => call.end({ type: "dismiss" })}>
+							<ButtonLike variant="secondary">キャンセル</ButtonLike>
+						</button>
+						<button type="submit">
+							<ButtonLike variant="primary">更新</ButtonLike>
+						</button>
+					</div>
+				</form>
+			</Dialog>
+		);
+	},
+);
diff --git a/workspaces/client/app/routes/dashboard/admin/events/internal/components/events-editor.tsx b/workspaces/client/app/routes/dashboard/admin/events/internal/components/events-editor.tsx
new file mode 100644
index 00000000..49f73784
--- /dev/null
+++ b/workspaces/client/app/routes/dashboard/admin/events/internal/components/events-editor.tsx
@@ -0,0 +1,217 @@
+import { useCallback, useState } from "react";
+import { ChevronLeft, ChevronRight, Edit, Plus, Trash } from "react-feather";
+import { css } from "styled-system/css";
+import { DeleteConfirmation } from "~/components/feature/delete-confirmation";
+import { ConfirmDialog } from "~/components/logic/callable/comfirm";
+import { ButtonLike } from "~/components/ui/button-like";
+import { IconButton } from "~/components/ui/icon-button";
+import { Table } from "~/components/ui/table";
+import { useCalendar } from "~/routes/dashboard/calendar/hooks/use-calendar";
+import type { CalendarEvent } from "~/types/event";
+import { useCreateEvent } from "../hooks/use-create-event";
+import { useDeleteEvent } from "../hooks/use-delete-event";
+import { useUpdateEvent } from "../hooks/use-update-event";
+import { CreateEventDialog } from "./callable-create-event-dialog";
+import { EditEventDialog } from "./callable-edit-event-dialog";
+
+const formatDuration = (startAt: Date, endAt: Date) => {
+	if (startAt.getDate() === endAt.getDate()) {
+		const startTimestamp = startAt.toLocaleTimeString("ja-JP", {
+			hour: "2-digit",
+			minute: "2-digit",
+		});
+		const endTimestamp = endAt.toLocaleTimeString("ja-JP", {
+			hour: "2-digit",
+			minute: "2-digit",
+		});
+		const date = startAt.toLocaleDateString("ja-JP", {
+			month: "2-digit",
+			day: "2-digit",
+			weekday: "short",
+		});
+		return `${date} ${startTimestamp} - ${endTimestamp}`;
+	}
+	return `${startAt.toLocaleDateString("ja-JP", {
+		month: "2-digit",
+		day: "2-digit",
+		weekday: "short",
+		hour: "2-digit",
+		minute: "2-digit",
+	})} - ${endAt.toLocaleDateString("ja-JP", {
+		month: "2-digit",
+		day: "2-digit",
+		weekday: "short",
+		hour: "2-digit",
+		minute: "2-digit",
+	})}`;
+};
+
+// dateから年度を取得する
+const getFiscalYear = (date: Date) => {
+	const year = date.getFullYear();
+	const month = date.getMonth() + 1;
+	if (month >= 4) {
+		return year;
+	}
+	return year - 1;
+};
+
+export const EventsEditor = () => {
+	const { data: events } = useCalendar();
+	// 選択中の年度
+	const [selectedFisicalYear, setSelectedFisicalYear] = useState(
+		getFiscalYear(new Date()),
+	);
+	const sortedEvents = [...events].sort(
+		(a, b) => b.startAt.getTime() - a.startAt.getTime(),
+	);
+	const { mutate: createEvent } = useCreateEvent();
+
+	const handleCreateEvent = useCallback(async () => {
+		const res = await CreateEventDialog.call();
+		if (res.type === "dismiss") return;
+		createEvent(res.payload);
+	}, [createEvent]);
+
+	const filteredEvents = sortedEvents.filter(
+		(event) =>
+			getFiscalYear(event.startAt) === selectedFisicalYear ||
+			getFiscalYear(event.endAt) === selectedFisicalYear,
+	);
+
+	return (
+		<div>
+			<div
+				className={css({
+					display: "flex",
+					justifyContent: "space-between",
+					alignItems: "center",
+					marginTop: 6,
+					marginBottom: 4,
+				})}
+			>
+				<div
+					className={css({
+						display: "flex",
+						gap: 2,
+						alignItems: "center",
+					})}
+				>
+					<h2
+						className={css({
+							fontSize: "xl",
+							fontWeight: "bold",
+							color: "gray.600",
+						})}
+					>
+						イベント一覧
+					</h2>
+					<div
+						className={css({ display: "flex", gap: 1, alignItems: "center" })}
+					>
+						<IconButton
+							type="button"
+							label="前の年度"
+							onClick={() => setSelectedFisicalYear(selectedFisicalYear - 1)}
+						>
+							<ChevronLeft size={16} />
+						</IconButton>
+						<span>{selectedFisicalYear}年度</span>
+						<IconButton
+							type="button"
+							label="次の年度"
+							onClick={() => setSelectedFisicalYear(selectedFisicalYear + 1)}
+						>
+							<ChevronRight size={16} />
+						</IconButton>
+					</div>
+				</div>
+				<button type="button" onClick={handleCreateEvent}>
+					<ButtonLike variant="primary" size="sm">
+						<Plus size={16} />
+						新規作成
+					</ButtonLike>
+				</button>
+			</div>
+			<Table.Root>
+				<thead>
+					<Table.Tr>
+						<Table.Th>タイトル</Table.Th>
+						<Table.Th>説明</Table.Th>
+						<Table.Th>期間</Table.Th>
+						<Table.Th>操作</Table.Th>
+					</Table.Tr>
+				</thead>
+				<tbody>
+					{filteredEvents.map((event) => (
+						<EventTableRow event={event} key={event.id} />
+					))}
+				</tbody>
+			</Table.Root>
+		</div>
+	);
+};
+
+const EventTableRow = ({ event }: { event: CalendarEvent }) => {
+	const { mutate: deleteEvent } = useDeleteEvent();
+	const { mutate: updateEvent } = useUpdateEvent();
+
+	const handleDeleteEvent = useCallback(async () => {
+		const res = await ConfirmDialog.call({
+			title: "イベントの削除",
+			children: <DeleteConfirmation title={event.title} />,
+			danger: true,
+		});
+		if (res.type === "dismiss") return;
+		deleteEvent(event);
+	}, [event, deleteEvent]);
+
+	const handleEditEvent = useCallback(async () => {
+		const res = await EditEventDialog.call({ event });
+		if (res.type === "dismiss") return;
+		updateEvent(res.payload);
+	}, [event, updateEvent]);
+
+	return (
+		<Table.Tr>
+			<Table.Td>{event.title}</Table.Td>
+			<Table.Td>
+				<div
+					className={css({
+						color: "gray.500",
+						fontSize: "sm",
+						display: "-webkit-box",
+						WebkitLineClamp: 2,
+						boxOrient: "vertical",
+						overflow: "hidden",
+					})}
+				>
+					{event.description}
+				</div>
+			</Table.Td>
+			<Table.Td>
+				<span
+					className={css({
+						color: "gray.500",
+						fontSize: "sm",
+						display: "flex",
+						justifyContent: "space-between",
+					})}
+				>
+					{formatDuration(event.startAt, event.endAt)}
+				</span>
+			</Table.Td>
+			<Table.Td>
+				<div className={css({ display: "flex", gap: 2 })}>
+					<IconButton label="編集" onClick={handleEditEvent}>
+						<Edit size={16} />
+					</IconButton>
+					/
+					<IconButton label="削除" color="danger" onClick={handleDeleteEvent}>
+						<Trash size={16} />
+					</IconButton>
+				</div>
+			</Table.Td>
+		</Table.Tr>
+	);
+};
diff --git a/workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-create-event.tsx b/workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-create-event.tsx
new file mode 100644
index 00000000..fe1456df
--- /dev/null
+++ b/workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-create-event.tsx
@@ -0,0 +1,31 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { CalendarEvent } from "~/types/event";
+
+export const useCreateEvent = () => {
+	const { calendarRepository } = useRepository();
+	const queryClient = useQueryClient();
+	const { pushToast } = useToast();
+	return useMutation({
+		mutationFn: async (event: Omit<CalendarEvent, "id" | "userId">) => {
+			await calendarRepository.createEvent(event);
+			return event;
+		},
+		onSuccess: (event) => {
+			pushToast({
+				title: `イベント「${event.title}」を作成しました`,
+				type: "success",
+			});
+			queryClient.invalidateQueries({
+				queryKey: calendarRepository.getAllEvents$$key(),
+			});
+		},
+		onError: () => {
+			pushToast({
+				title: "イベントの作成に失敗しました",
+				type: "error",
+			});
+		},
+	});
+};
diff --git a/workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-delete-event.tsx b/workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-delete-event.tsx
new file mode 100644
index 00000000..43e8c393
--- /dev/null
+++ b/workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-delete-event.tsx
@@ -0,0 +1,31 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { CalendarEvent } from "~/types/event";
+
+export const useDeleteEvent = () => {
+	const { calendarRepository } = useRepository();
+	const queryClient = useQueryClient();
+	const { pushToast } = useToast();
+	return useMutation({
+		mutationFn: async (event: CalendarEvent) => {
+			await calendarRepository.deleteEvent(event.id);
+			return event;
+		},
+		onSuccess: (event) => {
+			pushToast({
+				title: `イベント「${event.title}」を削除しました`,
+				type: "success",
+			});
+			queryClient.invalidateQueries({
+				queryKey: calendarRepository.getAllEvents$$key(),
+			});
+		},
+		onError: () => {
+			pushToast({
+				title: "イベントの削除に失敗しました",
+				type: "error",
+			});
+		},
+	});
+};
diff --git a/workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-update-event.tsx b/workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-update-event.tsx
new file mode 100644
index 00000000..0ad6bc52
--- /dev/null
+++ b/workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-update-event.tsx
@@ -0,0 +1,31 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { CalendarEvent } from "~/types/event";
+
+export const useUpdateEvent = () => {
+	const { calendarRepository } = useRepository();
+	const queryClient = useQueryClient();
+	const { pushToast } = useToast();
+	return useMutation({
+		mutationFn: async (event: CalendarEvent) => {
+			await calendarRepository.updateEvent(event);
+			return event;
+		},
+		onSuccess: (event) => {
+			pushToast({
+				title: `イベント「${event.title}」を更新しました`,
+				type: "success",
+			});
+			queryClient.invalidateQueries({
+				queryKey: calendarRepository.getAllEvents$$key(),
+			});
+		},
+		onError: () => {
+			pushToast({
+				title: "イベントの更新に失敗しました",
+				type: "error",
+			});
+		},
+	});
+};
diff --git a/workspaces/client/app/routes/dashboard/admin/events/page.tsx b/workspaces/client/app/routes/dashboard/admin/events/page.tsx
new file mode 100644
index 00000000..8bcff368
--- /dev/null
+++ b/workspaces/client/app/routes/dashboard/admin/events/page.tsx
@@ -0,0 +1,13 @@
+import { CreateEventDialog } from "./internal/components/callable-create-event-dialog";
+import { EditEventDialog } from "./internal/components/callable-edit-event-dialog";
+import { EventsEditor } from "./internal/components/events-editor";
+
+export default function AdminCertifications() {
+	return (
+		<div>
+			<EventsEditor />
+			<CreateEventDialog.Root />
+			<EditEventDialog.Root />
+		</div>
+	);
+}
diff --git a/workspaces/client/app/routes/dashboard/admin/layout.tsx b/workspaces/client/app/routes/dashboard/admin/layout.tsx
index d241b678..5bff029e 100644
--- a/workspaces/client/app/routes/dashboard/admin/layout.tsx
+++ b/workspaces/client/app/routes/dashboard/admin/layout.tsx
@@ -3,6 +3,7 @@ import { useEffect } from "react";
 import { Outlet, useNavigate } from "react-router";
 import { Tab } from "~/components/ui/tab";
 import { useAuth } from "~/hooks/use-auth";
+import { FLAG } from "~/utils/flag";
 import { DashboardHeader } from "../internal/components/dashboard-title";
 import { useCertificationRequests } from "./internal/hooks/use-certification-requests";
 
@@ -23,6 +24,15 @@ const NAVIGATION = [
 		isActive: (location: string) =>
 			location.startsWith("/admin/certifications"),
 	},
+	...(FLAG.ENABLE_CALENDAR
+		? [
+				{
+					label: "Events",
+					to: "/admin/events",
+					isActive: (location: string) => location.startsWith("/admin/events"),
+				},
+			]
+		: []),
 ];
 
 export default function AdminLayout() {
diff --git a/workspaces/client/app/routes/dashboard/calendar/hooks/use-calendar.ts b/workspaces/client/app/routes/dashboard/calendar/hooks/use-calendar.ts
index 38263952..8141b41f 100644
--- a/workspaces/client/app/routes/dashboard/calendar/hooks/use-calendar.ts
+++ b/workspaces/client/app/routes/dashboard/calendar/hooks/use-calendar.ts
@@ -4,7 +4,8 @@ import { useRepository } from "~/hooks/use-repository";
 export function useCalendar() {
 	const { calendarRepository } = useRepository();
 	return useQuery({
-		queryKey: [calendarRepository.getAllEvents$$key()],
+		queryKey: calendarRepository.getAllEvents$$key(),
 		queryFn: calendarRepository.getAllEvents,
+		initialData: [],
 	});
 }
diff --git a/workspaces/client/app/routes/dashboard/settings/internal/components/form.tsx b/workspaces/client/app/routes/dashboard/settings/internal/components/form.tsx
index 101c9322..7927f92e 100644
--- a/workspaces/client/app/routes/dashboard/settings/internal/components/form.tsx
+++ b/workspaces/client/app/routes/dashboard/settings/internal/components/form.tsx
@@ -11,9 +11,9 @@ import { ButtonLike } from "~/components/ui/button-like";
 import { Form } from "~/components/ui/form";
 import { ErrorDisplay } from "~/components/ui/form/error-display";
 import { Switch } from "~/components/ui/switch";
-import { BIO_MAX_LENGTH, GRADE } from "~/constant";
+import { GRADE } from "~/constant";
 import { useAuth } from "~/hooks/use-auth";
-import { UserSchemas } from "~/schema/user";
+import { BIO_MAX_LENGTH, BIO_MAX_LINES, UserSchemas } from "~/schema/user";
 import type { UserCertification } from "~/types/certification";
 import {
 	useCertifications,
@@ -244,7 +244,7 @@ export const ProfileUpdateForm = () => {
 					<div className={css({ height: "240px" })}>
 						<Form.Textarea
 							placeholder={`自己紹介を${BIO_MAX_LENGTH}文字以内で入力してください（Markdown使用可能）`}
-							rows={10}
+							rows={BIO_MAX_LINES}
 							{...register("bio")}
 						/>
 					</div>
diff --git a/workspaces/client/app/schema/event.ts b/workspaces/client/app/schema/event.ts
new file mode 100644
index 00000000..4c033f40
--- /dev/null
+++ b/workspaces/client/app/schema/event.ts
@@ -0,0 +1,32 @@
+import * as v from "valibot";
+import { MaxLines } from "~/utils/valibot";
+
+export const EVENT_DESCRIPTION_MAX_LENGTH = 255; // 説明は255文字まで許容
+export const EVENT_DESCRIPTION_MAX_LINES = 10; // 説明は10行まで許容
+
+export const EventSchemas = {
+	Title: v.pipe(
+		v.string(),
+		v.nonEmpty("タイトルを入力してください"),
+		v.maxLength(64, "タイトルは64文字以下で入力してください"),
+	),
+	Description: v.pipe(
+		v.string(),
+		v.nonEmpty("説明を入力してください"),
+		v.maxLength(
+			EVENT_DESCRIPTION_MAX_LENGTH,
+			"説明は255文字以下で入力してください",
+		),
+		MaxLines(EVENT_DESCRIPTION_MAX_LINES, "説明は10行以下で入力してください"),
+	),
+	StartAt: v.pipe(
+		v.string(),
+		v.transform((v) => new Date(v)),
+		v.date(),
+	),
+	EndAt: v.pipe(
+		v.string(),
+		v.transform((v) => new Date(v)),
+		v.date(),
+	),
+};
diff --git a/workspaces/client/app/schema/user.ts b/workspaces/client/app/schema/user.ts
index ff237a68..e69e13cd 100644
--- a/workspaces/client/app/schema/user.ts
+++ b/workspaces/client/app/schema/user.ts
@@ -1,9 +1,13 @@
 import * as v from "valibot";
-import { BIO_MAX_LENGTH, BIO_MAX_NEWLINE, RESERVED_WORDS } from "~/constant";
+import { RESERVED_WORDS } from "~/constant";
+import { MaxLines } from "~/utils/valibot";
 
 // 本名を表す文字列において、苗字、名前、ミドルネーム等が1つ以上の空文字で区切られている場合に受理される
 const realNamePattern = /^(?=.*\S(?:[\s　]+)\S).+$/;
 
+export const BIO_MAX_LENGTH = 255; // Bioは255文字まで許容
+export const BIO_MAX_LINES = 10; // Bioは10行まで許容
+
 export const UserSchemas = {
 	DisplayId: v.pipe(
 		v.string(),
@@ -69,12 +73,9 @@ export const UserSchemas = {
 			BIO_MAX_LENGTH,
 			`自己紹介は${BIO_MAX_LENGTH}文字以下で入力してください`,
 		),
-		v.custom((input) => {
-			const newlineCount = (input as string).match(/\n/g)?.length || 0;
-			if (newlineCount > BIO_MAX_NEWLINE) {
-				return false;
-			}
-			return true;
-		}, `自己紹介は${BIO_MAX_NEWLINE}行以下で入力してください`),
+		MaxLines(
+			BIO_MAX_LINES,
+			`自己紹介は${BIO_MAX_LINES}行以下で入力してください`,
+		),
 	),
 };
diff --git a/workspaces/client/app/utils/valibot.ts b/workspaces/client/app/utils/valibot.ts
new file mode 100644
index 00000000..be73bbdf
--- /dev/null
+++ b/workspaces/client/app/utils/valibot.ts
@@ -0,0 +1,9 @@
+import * as v from "valibot";
+
+export const MaxLines = (maxLines: number, error: string) =>
+	v.custom<string>((input) => {
+		if (typeof input !== "string") return false;
+		const lines = input.split("\n");
+		if (lines.length > maxLines) return false;
+		return true;
+	}, error);
diff --git a/workspaces/server/src/infrastructure/repository/cloudflare/calendar.ts b/workspaces/server/src/infrastructure/repository/cloudflare/calendar.ts
index 318aa78b..1092d132 100644
--- a/workspaces/server/src/infrastructure/repository/cloudflare/calendar.ts
+++ b/workspaces/server/src/infrastructure/repository/cloudflare/calendar.ts
@@ -39,7 +39,10 @@ export class CloudflareCalendarRepository implements ICalendarRepository {
 			startAt: event.startAt.toISOString(),
 			endAt: event.endAt.toISOString(),
 		};
-		await this.client.update(schema.calendarEvents).set(updatedEvent);
+		await this.client
+			.update(schema.calendarEvents)
+			.set(updatedEvent)
+			.where(eq(schema.calendarEvents.id, event.id));
 	}
 
 	async deleteEvent(eventId: ICalendarEvent["id"]): Promise<void> {
diff --git a/workspaces/server/src/routes/calendar.ts b/workspaces/server/src/routes/calendar.ts
index eb9b4153..80aa21a2 100644
--- a/workspaces/server/src/routes/calendar.ts
+++ b/workspaces/server/src/routes/calendar.ts
@@ -1,15 +1,15 @@
 import { vValidator } from "@hono/valibot-validator";
 import * as v from "valibot";
 import { factory } from "../factory";
+import { authMiddleware } from "../middleware/auth";
 
 const app = factory.createApp();
 
 const createEventSchema = v.object({
-	userId: v.pipe(v.string(), v.nonEmpty()),
 	title: v.pipe(v.string(), v.nonEmpty()),
 	description: v.pipe(v.string(), v.nonEmpty()),
-	startAt: v.pipe(v.string(), v.isoDateTime(), v.nonEmpty()),
-	endAt: v.pipe(v.string(), v.isoDateTime(), v.nonEmpty()),
+	startAt: v.pipe(v.string(), v.isoTimestamp(), v.nonEmpty()),
+	endAt: v.pipe(v.string(), v.isoTimestamp(), v.nonEmpty()),
 });
 
 const updateEventSchema = v.object({
@@ -17,8 +17,8 @@ const updateEventSchema = v.object({
 	userId: v.pipe(v.string(), v.nonEmpty()),
 	title: v.pipe(v.string(), v.nonEmpty()),
 	description: v.pipe(v.string(), v.nonEmpty()),
-	startAt: v.pipe(v.string(), v.isoDateTime(), v.nonEmpty()),
-	endAt: v.pipe(v.string(), v.isoDateTime(), v.nonEmpty()),
+	startAt: v.pipe(v.string(), v.isoTimestamp(), v.nonEmpty()),
+	endAt: v.pipe(v.string(), v.isoTimestamp(), v.nonEmpty()),
 });
 
 const deleteEventSchema = v.object({
@@ -26,7 +26,7 @@ const deleteEventSchema = v.object({
 });
 
 const route = app
-	.get("/events", async (c) => {
+	.get("/events", authMiddleware, async (c) => {
 		const { CalendarRepository } = c.var;
 		try {
 			const events = await CalendarRepository.getAllEvents();
@@ -35,44 +35,57 @@ const route = app
 			return c.json({ error: "events not found" }, 404);
 		}
 	})
-	.post("/events", vValidator("json", createEventSchema), async (c) => {
-		const { CalendarRepository } = c.var;
-		const { userId, title, description, startAt, endAt } = c.req.valid("json");
-		const eventPayload = {
-			userId,
-			title,
-			description,
-			startAt: new Date(startAt),
-			endAt: new Date(endAt),
-		};
-		try {
-			await CalendarRepository.createEvent(eventPayload);
-			return c.json({ message: "event created" });
-		} catch (e) {
-			return c.json({ error: "event not created" }, 404);
-		}
-	})
-	.put("/events/:eventId", vValidator("json", updateEventSchema), async (c) => {
-		const { CalendarRepository } = c.var;
-		const { id, userId, title, description, startAt, endAt } =
-			c.req.valid("json");
-		const eventPayload = {
-			id,
-			userId,
-			title,
-			description,
-			startAt: new Date(startAt),
-			endAt: new Date(endAt),
-		};
-		try {
-			await CalendarRepository.updateEvent(eventPayload);
-			return c.json({ message: "event updated" });
-		} catch (e) {
-			return c.json({ error: "event not updated" }, 500);
-		}
-	})
+	.post(
+		"/events",
+		authMiddleware,
+		vValidator("json", createEventSchema),
+		async (c) => {
+			const { CalendarRepository } = c.var;
+			const { userId } = c.get("jwtPayload");
+			const { title, description, startAt, endAt } = c.req.valid("json");
+			const eventPayload = {
+				userId,
+				title,
+				description,
+				startAt: new Date(startAt),
+				endAt: new Date(endAt),
+			};
+			try {
+				await CalendarRepository.createEvent(eventPayload);
+				return c.json({ message: "event created" });
+			} catch (e) {
+				return c.json({ error: "event not created" }, 404);
+			}
+		},
+	)
+	.put(
+		"/events/:eventId",
+		authMiddleware,
+		vValidator("json", updateEventSchema),
+		async (c) => {
+			const { CalendarRepository } = c.var;
+			const { id, userId, title, description, startAt, endAt } =
+				c.req.valid("json");
+			const eventPayload = {
+				id,
+				userId,
+				title,
+				description,
+				startAt: new Date(startAt),
+				endAt: new Date(endAt),
+			};
+			try {
+				await CalendarRepository.updateEvent(eventPayload);
+				return c.json({ message: "event updated" });
+			} catch (e) {
+				console.error(e);
+				return c.json({ error: "failed to update event" }, 500);
+			}
+		},
+	)
 	.delete(
 		"/events/:eventId",
+		authMiddleware,
 		vValidator("json", deleteEventSchema),
 		async (c) => {
 			const { CalendarRepository } = c.var;


Reviews:
user: login: sor4chi
body: userIdみたいな識別情報はパラメータで渡すようにすると予期せぬ変更がされる場合があるので必ずmiddlewareからとりましょう
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T07:02:24Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T14:48:29Z
user: login: sor4chi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-17T20:31:47Z
user: login: sor4chi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T03:25:35Z
user: login: a01sa01to
body: 動作チェックはしばしお待ちを...
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T04:19:21Z
user: login: sor4chi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T04:37:09Z
user: login: sor4chi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T04:37:41Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T12:33:57Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T12:35:05Z
user: login: sor4chi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T12:36:32Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T12:42:48Z
user: login: sor4chi
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T12:45:41Z
user: login: a01sa01to
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T12:51:20Z
user: login: a01sa01to
body: "+ 新規作成" ちっちゃくない？感はあるがまあよさそう
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T13:57:19Z
user: login: a01sa01to
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T13:15:47Z

Review comments:
diff_hunk: @@ -0,0 +1,23 @@
+import * as v from "valibot";
+import { BIO_MAX_LENGTH } from "~/constant";
path: workspaces/client/app/schema/event.ts
user: login: a01sa01to
body: 値同じとはいえ、さすがに名前変えませんか
created_at: 2025-03-17T14:41:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,31 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { CalendarEvent } from "~/types/event";
+
+export const useUpdateEvent = () => {
+	const { calendarRepository } = useRepository();
+	const queryClient = useQueryClient();
+	const { pushToast } = useToast();
+	return useMutation({
+		mutationFn: async (event: CalendarEvent) => {
+			await calendarRepository.updateEvent(event);
+			return event;
+		},
+		onSuccess: (event) => {
+			pushToast({
+				title: `イベント ${event.title} を更新しました`,
path: workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-update-event.tsx
user: login: a01sa01to
body: OAuth App でやっているように `「」` で囲ったほうがいいかも (削除・追加も同様)
created_at: 2025-03-17T14:44:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,152 @@
+import { valibotResolver } from "@hookform/resolvers/valibot";
+import { createCallable } from "react-call";
+import { useForm } from "react-hook-form";
+import { css } from "styled-system/css";
+import * as v from "valibot";
+import { ButtonLike } from "~/components/ui/button-like";
+import { Dialog } from "~/components/ui/dialog";
+import { Form } from "~/components/ui/form";
+import { ErrorDisplay } from "~/components/ui/form/error-display";
+import { EventSchemas } from "~/schema/event";
+import type { CalendarEvent } from "~/types/event";
+
+interface Props {
+	event: CalendarEvent;
+}
+
+type Payload =
+	| {
+			type: "success";
+			payload: CalendarEvent;
+	  }
+	| {
+			type: "dismiss";
+	  };
+
+const UpdateFormSchema = v.object({
+	title: EventSchemas.Title,
+	description: EventSchemas.Description,
+	startAt: EventSchemas.StartAt,
+	endAt: EventSchemas.EndAt,
+});
+
+type UpdateFormValues = v.InferInput<typeof UpdateFormSchema>;
+
+// HTMLの`datetime-local`のフォーマットは`YYYY-MM-DDTHH:mm`なので、タイムゾーンが含まれているとdefaultValueが機能しない
+// スウェーデンのタイムゾーンでフォーマットがそれなので`sv-SE`を指定
+const formatHTMLDate = (date: Date) =>
+	date.toLocaleTimeString("sv-SE", {
+		year: "numeric",
+		month: "2-digit",
+		day: "2-digit",
+		hour: "2-digit",
+		minute: "2-digit",
+	});
path: workspaces/client/app/routes/dashboard/admin/events/internal/components/callable-edit-event-dialog.tsx
user: login: a01sa01to
body: 実証できてないのでわからないんだけど、 JST との誤差とか大丈夫そう？
created_at: 2025-03-17T14:48:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,152 @@
+import { valibotResolver } from "@hookform/resolvers/valibot";
+import { createCallable } from "react-call";
+import { useForm } from "react-hook-form";
+import { css } from "styled-system/css";
+import * as v from "valibot";
+import { ButtonLike } from "~/components/ui/button-like";
+import { Dialog } from "~/components/ui/dialog";
+import { Form } from "~/components/ui/form";
+import { ErrorDisplay } from "~/components/ui/form/error-display";
+import { EventSchemas } from "~/schema/event";
+import type { CalendarEvent } from "~/types/event";
+
+interface Props {
+	event: CalendarEvent;
+}
+
+type Payload =
+	| {
+			type: "success";
+			payload: CalendarEvent;
+	  }
+	| {
+			type: "dismiss";
+	  };
+
+const UpdateFormSchema = v.object({
+	title: EventSchemas.Title,
+	description: EventSchemas.Description,
+	startAt: EventSchemas.StartAt,
+	endAt: EventSchemas.EndAt,
+});
+
+type UpdateFormValues = v.InferInput<typeof UpdateFormSchema>;
+
+// HTMLの`datetime-local`のフォーマットは`YYYY-MM-DDTHH:mm`なので、タイムゾーンが含まれているとdefaultValueが機能しない
+// スウェーデンのタイムゾーンでフォーマットがそれなので`sv-SE`を指定
+const formatHTMLDate = (date: Date) =>
+	date.toLocaleTimeString("sv-SE", {
+		year: "numeric",
+		month: "2-digit",
+		day: "2-digit",
+		hour: "2-digit",
+		minute: "2-digit",
+	});
path: workspaces/client/app/routes/dashboard/admin/events/internal/components/callable-edit-event-dialog.tsx
user: login: sor4chi
body: そもそもhtmlのinput type datetime-localeがTZ考慮しない値なので…
じゃあバックエンドへのリクエストがISOであるのはおかしいか
created_at: 2025-03-17T20:31:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,23 @@
+import * as v from "valibot";
+import { BIO_MAX_LENGTH } from "~/constant";
path: workspaces/client/app/schema/event.ts
user: login: sor4chi
body: 忘れたた
created_at: 2025-03-18T03:25:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,217 @@
+import { useCallback, useState } from "react";
+import { ChevronLeft, ChevronRight, Edit, Plus, Trash } from "react-feather";
+import { css } from "styled-system/css";
+import { DeleteConfirmation } from "~/components/feature/delete-confirmation";
+import { ConfirmDialog } from "~/components/logic/callable/comfirm";
+import { ButtonLike } from "~/components/ui/button-like";
+import { IconButton } from "~/components/ui/icon-button";
+import { Table } from "~/components/ui/table";
+import { useCalendar } from "~/routes/dashboard/calendar/hooks/use-calendar";
+import type { CalendarEvent } from "~/types/event";
+import { useCreateEvent } from "../hooks/use-create-event";
+import { useDeleteEvent } from "../hooks/use-delete-event";
+import { useUpdateEvent } from "../hooks/use-update-event";
+import { CreateEventDialog } from "./callable-create-event-dialog";
+import { EditEventDialog } from "./callable-edit-event-dialog";
+
+const formatTerm = (startAt: Date, endAt: Date) => {
path: workspaces/client/app/routes/dashboard/admin/events/internal/components/events-editor.tsx
user: login: a01sa01to
body: Term より Duration がしっくりくるかも？
created_at: 2025-03-18T04:15:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,31 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { CalendarEvent } from "~/types/event";
+
+export const useCreateEvent = () => {
+	const { calendarRepository } = useRepository();
+	const queryClient = useQueryClient();
+	const { pushToast } = useToast();
+	return useMutation({
+		mutationFn: async (event: Omit<CalendarEvent, "id" | "userId">) => {
+			await calendarRepository.createEvent(event);
+			return event;
+		},
+		onSuccess: (event) => {
+			pushToast({
+				title: `イベント ${event.title} を作成しました`,
path: workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-create-event.tsx
user: login: a01sa01to
body: 「」
created_at: 2025-03-18T04:16:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,31 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRepository } from "~/hooks/use-repository";
+import { useToast } from "~/hooks/use-toast";
+import type { CalendarEvent } from "~/types/event";
+
+export const useDeleteEvent = () => {
+	const { calendarRepository } = useRepository();
+	const queryClient = useQueryClient();
+	const { pushToast } = useToast();
+	return useMutation({
+		mutationFn: async (event: CalendarEvent) => {
+			await calendarRepository.deleteEvent(event.id);
+			return event;
+		},
+		onSuccess: (event) => {
+			pushToast({
+				title: `イベント ${event.title} を削除しました`,
path: workspaces/client/app/routes/dashboard/admin/events/internal/hooks/use-delete-event.tsx
user: login: a01sa01to
body: 「」
created_at: 2025-03-18T04:16:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,32 @@
+import * as v from "valibot";
+import { MaxLines } from "~/utils/valibot";
+
+export const EVENT_DESCRIPTION_MAX_LENGTH = 255; // 説明は255文字まで許容
+export const EVENT_DESCRIPTION_MAX_LINES = 10; // 説明は10行まで許容
+
+export const EventSchemas = {
+	Title: v.pipe(
+		v.string(),
+		v.nonEmpty("タイトルを入力してください"),
+		v.maxLength(64, "タイトルは64文字以下で入力してください"),
path: workspaces/client/app/schema/event.ts
user: login: a01sa01to
body: 64 const 化する？
created_at: 2025-03-18T04:17:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,9 @@
+import * as v from "valibot";
+
+export const MaxLines = (maxLines: number, error: string) =>
+	v.custom<string>((input) => {
+		if (typeof input !== "string") return false;
+		const lines = input.split("\n");
path: workspaces/client/app/utils/valibot.ts
user: login: a01sa01to
body: resolve #89 しそう
created_at: 2025-03-18T04:18:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,9 @@
+import * as v from "valibot";
+
+export const MaxLines = (maxLines: number, error: string) =>
+	v.custom<string>((input) => {
+		if (typeof input !== "string") return false;
+		const lines = input.split("\n");
path: workspaces/client/app/utils/valibot.ts
user: login: sor4chi
body: これline splitして行数数えてるので前の\nの個数数えるより+1多いよ
created_at: 2025-03-18T04:37:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,32 @@
+import * as v from "valibot";
+import { MaxLines } from "~/utils/valibot";
+
+export const EVENT_DESCRIPTION_MAX_LENGTH = 255; // 説明は255文字まで許容
+export const EVENT_DESCRIPTION_MAX_LINES = 10; // 説明は10行まで許容
+
+export const EventSchemas = {
+	Title: v.pipe(
+		v.string(),
+		v.nonEmpty("タイトルを入力してください"),
+		v.maxLength(64, "タイトルは64文字以下で入力してください"),
path: workspaces/client/app/schema/event.ts
user: login: sor4chi
body: 特に他で使うところないからいいかな
一応OAuth系のschemaもマジックナンバーになってるよ
created_at: 2025-03-18T04:37:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,32 @@
+import * as v from "valibot";
+import { MaxLines } from "~/utils/valibot";
+
+export const EVENT_DESCRIPTION_MAX_LENGTH = 255; // 説明は255文字まで許容
+export const EVENT_DESCRIPTION_MAX_LINES = 10; // 説明は10行まで許容
+
+export const EventSchemas = {
+	Title: v.pipe(
+		v.string(),
+		v.nonEmpty("タイトルを入力してください"),
+		v.maxLength(64, "タイトルは64文字以下で入力してください"),
path: workspaces/client/app/schema/event.ts
user: login: a01sa01to
body: ありゃ　そのままでいいか
created_at: 2025-03-18T12:33:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,9 @@
+import * as v from "valibot";
+
+export const MaxLines = (maxLines: number, error: string) =>
+	v.custom<string>((input) => {
+		if (typeof input !== "string") return false;
+		const lines = input.split("\n");
path: workspaces/client/app/utils/valibot.ts
user: login: a01sa01to
body: 11 行目入ろうとすると lines.length === 11 にならない？
created_at: 2025-03-18T12:35:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,9 @@
+import * as v from "valibot";
+
+export const MaxLines = (maxLines: number, error: string) =>
+	v.custom<string>((input) => {
+		if (typeof input !== "string") return false;
+		const lines = input.split("\n");
path: workspaces/client/app/utils/valibot.ts
user: login: sor4chi
body: あれ大なりイコールにしたつもりだった
created_at: 2025-03-18T12:36:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,9 @@
+import * as v from "valibot";
+
+export const MaxLines = (maxLines: number, error: string) =>
+	v.custom<string>((input) => {
+		if (typeof input !== "string") return false;
+		const lines = input.split("\n");
path: workspaces/client/app/utils/valibot.ts
user: login: a01sa01to
body: ん、これだと 10 行目まで入れられなくないか？

```plaintext
1
2
3
4
5
6
7
8
9
10
```
だと split.length が 10 になって `>=` が true になりそう
created_at: 2025-03-18T12:42:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,9 @@
+import * as v from "valibot";
+
+export const MaxLines = (maxLines: number, error: string) =>
+	v.custom<string>((input) => {
+		if (typeof input !== "string") return false;
+		const lines = input.split("\n");
path: workspaces/client/app/utils/valibot.ts
user: login: sor4chi
body: > resolve #89 しそう

あ、これ89のissueと同じこと起こりそうっていう指摘かと思ったw
ごめん直す必要なかったか
created_at: 2025-03-18T12:45:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,9 @@
+import * as v from "valibot";
+
+export const MaxLines = (maxLines: number, error: string) =>
+	v.custom<string>((input) => {
+		if (typeof input !== "string") return false;
+		const lines = input.split("\n");
path: workspaces/client/app/utils/valibot.ts
user: login: a01sa01to
body: 「"resolve #89" しそう」の意でした...

created_at: 2025-03-18T12:51:20Z
author_association: MEMBER

PR comments:
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-03-17T07:02:05Z
body: ## Deploying id with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>25838c2</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://6fc7b460.id-131.pages.dev'>https://6fc7b460.id-131.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://feat-events-editor.id-131.pages.dev'>https://feat-events-editor.id-131.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/338e04f1067dcfb64f701ebc7c5041a0/pages/view/id/6fc7b460-ae1c-4c7a-9d4a-a6bfc245f0d8)

author_association: NONE
user: login: batora9
created_at: 2025-03-18T17:15:49Z
body: Adminじゃないのでみれません...
author_association: CONTRIBUTOR
user: login: a01sa01to
created_at: 2025-03-18T23:03:22Z
body: > Adminじゃないのでみれません...

Local の話だよね？ DB 直書きしましょう

author_association: MEMBER
user: login: sor4chi
created_at: 2025-03-19T01:02:42Z
body: cd workspaces/server
pnpm seed
ユーザーロール編集 -> 自分のdisplayId入力 -> adminオン
author_association: MEMBER