diff --git a/client/package.json b/client/package.json index 6ac4ae6..e60c018 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "@hookform/resolvers": "^4.1.3", "@tailwindcss/vite": "^4.0.13", "dayjs": "^1.11.13", + "ics": "^3.12.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", diff --git a/client/src/components/AddToCalendar.tsx b/client/src/components/AddToCalendar.tsx new file mode 100644 index 0000000..fd827e2 --- /dev/null +++ b/client/src/components/AddToCalendar.tsx @@ -0,0 +1,75 @@ +import { LuCalendarPlus, LuCircleHelp } from "react-icons/lu"; +import { Tooltip } from "react-tooltip"; +import { DEFAULT_PARTICIPATION_OPTION } from "../../../common/colors"; +import { generateIcs } from "../lib/generateIcs"; +import type { Slot } from "../types"; + +type Props = { + projectName: string; + projectId: string; + projectDescription?: string | null; + slots: Pick[]; + participationOptionIdToLabel: Record; + // 参加形態がデフォルト値のみ(=実質未設定)かどうかの判定に使う + participationOptionCount: number; +}; + +/** + * 自分の提出済み日程をカレンダーアプリに追加するボタン。 + * その場で ics を生成してダウンロードさせ、各カレンダーアプリのインポート機能で読み込んでもらう。 + */ +export function AddToCalendar({ + projectName, + projectId, + projectDescription, + slots, + participationOptionIdToLabel, + participationOptionCount, +}: Props) { + const handleClick = () => { + const eventUrl = `${window.location.origin}/e/${projectId}`; + const icsContent = generateIcs( + projectName, + slots.map((slot) => { + const label = participationOptionIdToLabel[slot.participationOptionId] ?? ""; + // 参加形態がデフォルト値のみで他に選択肢がない場合、タイトルには含めない + const isDefaultOnly = participationOptionCount <= 1 && label === DEFAULT_PARTICIPATION_OPTION.label; + return { + from: slot.from, + to: slot.to, + label: isDefaultOnly ? "" : label, + }; + }), + eventUrl, + projectDescription ?? undefined, + ); + + const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "itsuhima.ics"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ + + +
+ ); +} diff --git a/client/src/lib/generateIcs.ts b/client/src/lib/generateIcs.ts new file mode 100644 index 0000000..ff9fb94 --- /dev/null +++ b/client/src/lib/generateIcs.ts @@ -0,0 +1,45 @@ +import { createEvents, type DateArray } from "ics"; +import type { Dayjs } from "./dayjs"; + +type SlotForIcs = { + from: Dayjs; + to: Dayjs; + label: string; +}; + +function toUtcDateArray(date: Dayjs): DateArray { + const utcDate = date.utc(); + return [utcDate.year(), utcDate.month() + 1, utcDate.date(), utcDate.hour(), utcDate.minute()]; +} + +export function generateIcs( + projectName: string, + slots: SlotForIcs[], + eventUrl?: string, + projectDescription?: string, +): string { + const description = [ + "イツヒマで提出した参加候補日程です。", + eventUrl ? `イベントページ: ${eventUrl}` : null, + projectDescription ? `\n${projectDescription}` : null, + ] + .filter(Boolean) + .join("\n"); + + const { error, value } = createEvents( + slots.map((slot) => ({ + title: `【候補】${projectName}${slot.label ? `(${slot.label})` : ""} - イツヒマ`, + description, + start: toUtcDateArray(slot.from), + startInputType: "utc", + end: toUtcDateArray(slot.to), + endInputType: "utc", + })), + ); + + if (error || !value) { + throw error ?? new Error("ics の生成に失敗しました。"); + } + + return value; +} diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index ac5203f..7de8c2f 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -15,6 +15,7 @@ import { } from "react-icons/lu"; import { NavLink, useParams } from "react-router"; import type { AppType } from "../../../../server/src/main"; +import { AddToCalendar } from "../../components/AddToCalendar"; import { Calendar } from "../../components/Calendar"; import Header from "../../components/Header"; import { projectReviver } from "../../revivers"; @@ -238,6 +239,11 @@ export default function SubmissionPage() { return Object.fromEntries(project.guests.filter((g) => g.comment).map((g) => [g.id, g.comment as string])); }, [project]); + const participationOptionIdToLabel = useMemo(() => { + if (!project) return {}; + return Object.fromEntries(project.participationOptions.map((opt) => [opt.id, opt.label])); + }, [project]); + const viewingSlots = useMemo(() => { if (!project) return []; @@ -340,6 +346,18 @@ export default function SubmissionPage() { })()} + {/* 自分の日程をカレンダーアプリに追加 */} + {mode === "view" && meAsGuest && meAsGuest.slots.length > 0 && ( + + )} + {/* 参加形態選択ボタン */} {mode === "edit" && project.participationOptions.length > 1 && selectedParticipationOptionId !== null && (
diff --git a/common/colors.ts b/common/colors.ts index 39b3d60..4cc72fb 100644 --- a/common/colors.ts +++ b/common/colors.ts @@ -7,7 +7,7 @@ export const PREDEFINED_COLORS = [ ]; export const DEFAULT_PARTICIPATION_OPTION = { - label: "参加", + label: "通常", color: "#0F82B1", // PRIMARY_RGB と同じ色 }; diff --git a/package-lock.json b/package-lock.json index 835c8ab..a0f6b46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@hookform/resolvers": "^4.1.3", "@tailwindcss/vite": "^4.0.13", "dayjs": "^1.11.13", + "ics": "^3.12.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", @@ -532,7 +533,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -549,7 +549,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -566,7 +565,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -583,7 +581,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -600,7 +597,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -617,7 +613,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -634,7 +629,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -651,7 +645,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -668,7 +661,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -685,7 +677,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -702,7 +693,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -719,7 +709,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -736,7 +725,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -753,7 +741,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -770,7 +757,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -787,7 +773,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -804,7 +789,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -821,7 +805,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -838,7 +821,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -855,7 +837,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -872,7 +853,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -889,7 +869,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -906,7 +885,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -923,7 +901,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -940,7 +917,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -957,7 +933,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2317,6 +2292,35 @@ "node": ">=16.9.0" } }, + "node_modules/ics": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/ics/-/ics-3.12.0.tgz", + "integrity": "sha512-BBPiZ/TbUyZrxui7SHKGOa/n2HUxaW9ucn7cw47NW9OSKe5tItYxIdN7Uczti5nKURmfwVvYi2aLigCJGH1+cA==", + "license": "ISC", + "dependencies": { + "nanoid": "^3.1.23", + "runes2": "^1.1.2", + "yup": "^1.2.0" + } + }, + "node_modules/ics/node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2814,6 +2818,12 @@ } } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -2992,6 +3002,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/runes2": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz", + "integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3046,6 +3062,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", @@ -3072,6 +3094,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/tsx": { "version": "4.22.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", @@ -3091,6 +3119,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -3681,6 +3721,18 @@ "dev": true, "license": "ISC" }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",