Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 75 additions & 0 deletions client/src/components/AddToCalendar.tsx
Original file line number Diff line number Diff line change
@@ -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<Slot, "from" | "to" | "participationOptionId">[];
participationOptionIdToLabel: Record<string, string>;
// 参加形態がデフォルト値のみ(=実質未設定)かどうかの判定に使う
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 (
<div className="mt-2 flex items-center gap-1">
<button type="button" onClick={handleClick} className="btn btn-sm btn-outline gap-1.5">
<LuCalendarPlus className="h-4 w-4" />
<span>カレンダー追加 (β)</span>
</button>
<button
type="button"
aria-label="カレンダー追加についての説明"
data-tooltip-id="add-to-calendar-info"
data-tooltip-content="自分の候補日程をカレンダーに追加します。ダウンロードされる .ics ファイルを開き、お使いのカレンダーにインポートしてください。(ベータ版)"
data-tooltip-place="top"
className="btn btn-circle btn-ghost btn-xs text-base-content/50"
>
<LuCircleHelp className="h-4 w-4" />
</button>
<Tooltip id="add-to-calendar-info" openOnClick className="z-50 max-w-70" style={{ textAlign: "left" }} />
</div>
);
}
45 changes: 45 additions & 0 deletions client/src/lib/generateIcs.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions client/src/pages/eventId/Submission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 [];

Expand Down Expand Up @@ -340,6 +346,18 @@ export default function SubmissionPage() {
})()}
</div>

{/* 自分の日程をカレンダーアプリに追加 */}
{mode === "view" && meAsGuest && meAsGuest.slots.length > 0 && (
<AddToCalendar
projectName={project.name}
projectId={projectId ?? ""}
projectDescription={project.description}
slots={meAsGuest.slots}
participationOptionIdToLabel={participationOptionIdToLabel}
participationOptionCount={project.participationOptions.length}
/>
)}

{/* 参加形態選択ボタン */}
{mode === "edit" && project.participationOptions.length > 1 && selectedParticipationOptionId !== null && (
<div className="mt-3 mb-2 flex flex-wrap items-center gap-1.5">
Expand Down
2 changes: 1 addition & 1 deletion common/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const PREDEFINED_COLORS = [
];

export const DEFAULT_PARTICIPATION_OPTION = {
label: "参加",
label: "通常",
color: "#0F82B1", // PRIMARY_RGB と同じ色
};

Expand Down
Loading
Loading