You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

458 lines
15 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// frontend/components/calendar/CalendarClient.tsx — 일정 페이지 오케스트레이터 (실제 날짜 기반)
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { useParams } from "next/navigation";
import { Icon } from "@/components/Icon";
import { useDialog } from "@/components/Dialog";
import { calendarApi } from "@/lib/calendar/api";
import { loadLS, saveLS } from "@/lib/calendar/store";
import {
addDays,
addMonths,
dayNum,
monthNum,
weekDatesOf,
yearNum,
} from "@/lib/calendar/time";
import type { Calendar, CalEvent, EventWrite, WeekBundle } from "@/lib/calendar/types";
import { MiniCal } from "./MiniCal";
import { CalToggleList } from "./CalToggleList";
import { WeekView } from "./WeekView";
import { MonthView } from "./MonthView";
import { DayView } from "./DayView";
import { EvPopover } from "./EvPopover";
import { EvEditor } from "./EvEditor";
import { MeetDrawer } from "./MeetDrawer";
const VIEWS = [
{ id: "week", label: "주" },
{ id: "month", label: "월" },
{ id: "day", label: "일" },
];
function countByDate(events: CalEvent[]): Record<string, number> {
const m: Record<string, number> = {};
events.forEach((e) => {
m[e.date] = (m[e.date] || 0) + 1;
});
return m;
}
function CalendarSkeleton() {
return (
<div className="calpage">
<div className="twork" aria-busy="true">
<aside className="subnav">
<div className="sn-title"></div>
<div className="cal-skel-col">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="cal-skel" style={{ height: 34 }} />
))}
</div>
</aside>
<main className="cmain">
<div className="cal-skel" style={{ height: 40, width: 240, marginBottom: 16 }} />
<div className="cpanel">
<div className="cal-skel" style={{ height: 420 }} />
</div>
</main>
</div>
</div>
);
}
export function CalendarClient() {
const { confirm, alert } = useDialog();
const [bundle, setBundle] = useState<WeekBundle | null>(null);
const [err, setErr] = useState(false);
const [view, setView] = useState<string>("week");
const [refDate, setRefDate] = useState<string>(""); // 네비게이션 기준일(ISO)
const [selDate, setSelDate] = useState<string>(""); // 일 뷰 선택일(ISO)
const [calOn, setCalOn] = useState<Record<string, boolean>>({});
const [focusOn, setFocusOn] = useState<boolean>(true);
const [hydrated, setHydrated] = useState(false);
const [pop, setPop] = useState<{ ev: CalEvent; x: number; y: number } | null>(null);
const [meetEv, setMeetEv] = useState<CalEvent | null>(null);
const [editor, setEditor] = useState<{
ev: CalEvent | null;
date?: string;
time?: string;
} | null>(null);
const shellRef = useRef<HTMLDivElement>(null);
const didMount = useRef(false);
// URL 로 열린 일정을 표현 — /calendar/<id> 의 <id> 세그먼트(옵셔널 캐치올).
// 주소 갱신은 router 대신 history.replaceState(얕은 갱신) — router.replace 는 이 라우트를
// 리마운트시켜 주간 네비게이션·상태가 날아가기 때문.
const params = useParams();
const routeId = Array.isArray(params.slug) ? params.slug[0] : undefined;
// 클라이언트에서만 localStorage 복원 (SSR 안정). 날짜는 항상 오늘에서 시작(stale 방지).
useEffect(() => {
setView(loadLS("view", "week"));
setCalOn(loadLS<Record<string, boolean>>("calOn", {}));
setFocusOn(loadLS("focusOn", true));
setHydrated(true);
}, []);
const load = () => {
setErr(false);
calendarApi
.week()
.then((b) => {
setBundle(b);
setCalOn((cur) =>
Object.keys(cur).length ? cur : Object.fromEntries(b.calendars.map((c) => [c.id, c.on])),
);
setRefDate((cur) => cur || b.today_date);
setSelDate((cur) => cur || b.today_date);
})
.catch(() => setErr(true));
};
useEffect(load, []);
// 백엔드 자동 풀링이 적재한 새 일정을 주기적으로(10초)·포커스 시 반영(네비게이션 상태는 유지).
useEffect(() => {
const refetch = () => {
calendarApi
.week()
.then((b) => setBundle(b))
.catch(() => {});
};
const id = setInterval(refetch, 10_000);
window.addEventListener("focus", refetch);
return () => {
clearInterval(id);
window.removeEventListener("focus", refetch);
};
}, []);
useEffect(() => {
if (hydrated) saveLS("view", view);
}, [view, hydrated]);
useEffect(() => {
if (hydrated) saveLS("calOn", calOn);
}, [calOn, hydrated]);
useEffect(() => {
if (hydrated) saveLS("focusOn", focusOn);
}, [focusOn, hydrated]);
useEffect(() => {
const el = shellRef.current;
const id = requestAnimationFrame(() => el && el.classList.add("entered"));
return () => cancelAnimationFrame(id);
}, [bundle]);
// URL → 일정: routeId 가 가리키는 일정이 있으면 그 주로 이동 + 팝오버 오픈(직접 입력·뒤로가기·딥링크).
useEffect(() => {
if (!bundle) return; // 번들 로드 후에만 탐색 가능
if (!routeId) {
setPop((p) => (p ? null : p)); // 주소에서 빠지면 팝오버 닫기
return;
}
if (pop?.ev.id === routeId) return; // 이미 열려 있음
const ev = bundle.events.find((e) => e.id === routeId);
if (!ev) return; // 없는 id → 무시(잘못된 주소)
setRefDate(ev.date);
setSelDate(ev.date);
setPop({ ev, x: Math.max(window.innerWidth / 2 - 150, 16), y: 140 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeId, bundle]);
// 일정 → URL: 팝오버로 연 일정에 주소를 맞춘다(초기 마운트는 위 effect 가 처리하므로 skip).
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
const id = pop?.ev.id;
const target = id ? `/calendar/${id}` : "/calendar";
if (window.location.pathname !== target) window.history.replaceState(null, "", target);
}, [pop]);
if (err && !bundle) {
return (
<div className="calpage">
<div className="cal-error" role="alert">
<Icon name="x" />
<button onClick={load}> </button>
</div>
</div>
);
}
if (!bundle) return <CalendarSkeleton />;
const today = bundle.today_date;
const ref = refDate || today;
const sel = selDate || today;
const weekDates = weekDatesOf(ref);
const calMap: Record<string, Calendar> = Object.fromEntries(
bundle.calendars.map((c) => [c.id, c]),
);
// 안전망: 이벤트가 참조하는 캘린더가 목록에 없어도 렌더가 깨지지 않도록 기본값 보강.
for (const e of bundle.events) {
if (!calMap[e.cal]) {
calMap[e.cal] = { id: e.cal, name: e.cal || "기타", tone: "ink", on: true, count: 0 };
}
}
const events = bundle.events.filter((e) => calOn[e.cal] !== false);
const focusBlocks = focusOn ? bundle.focus_blocks : [];
const openEv = (ev: CalEvent, e: MouseEvent) =>
setPop({ ev, x: e.clientX + 8, y: e.clientY + 8 });
const openMeet = (ev: CalEvent) => {
setPop(null);
setMeetEv(ev);
};
const afterWrite = () => {
setEditor(null);
setPop(null);
load();
};
const deleteEvent = async (ev: CalEvent) => {
setPop(null);
const ok = await confirm({
title: "일정 삭제",
message: `${ev.title} 일정을 삭제할까요?`,
confirmText: "삭제",
tone: "danger",
});
if (!ok) return;
try {
await calendarApi.deleteEvent(ev.id);
load();
} catch (e) {
alert({ title: "삭제 실패", message: e instanceof Error ? e.message : "삭제하지 못했어요" });
}
};
// 빈 시간 슬롯 클릭 → 해당 날짜·시각으로 생성 모달 오픈.
const openSlot = (date: string, time: string) =>
setEditor({ ev: null, date, time });
// 팝오버 "내 작업에 추가" → 회의 액션 전체를 작업으로 변환.
const addToTasks = async (ev: CalEvent) => {
await calendarApi.materializeAll(ev.id);
};
// 드래그 이동/리사이즈 → 날짜·시각 변경을 서버에 반영(낙관적 갱신 후 재로드).
const evToWrite = (ev: CalEvent, patch: Partial<EventWrite>): EventWrite => ({
title: ev.title,
date: ev.date,
start: ev.start,
end: ev.end,
loc: ev.loc,
note: ev.note,
people: ev.people,
cal: ev.cal,
rrule: ev.rrule,
reminders: ev.reminders,
...patch,
});
const moveEvent = (ev: CalEvent, patch: { date: string; start: string; end: string }) => {
setBundle(
(b) =>
b && {
...b,
events: b.events.map((e) =>
e.id === ev.id
? { ...e, ...patch, day: Number(patch.date.slice(8, 10)) || e.day }
: e,
),
},
);
calendarApi
.updateEvent(ev.id, evToWrite(ev, patch))
.then(load)
.catch((e) => {
alert({
title: "이동 실패",
message: e instanceof Error ? e.message : "일정을 옮기지 못했어요",
});
load();
});
};
// 초대 응답(RSVP) → 서버 반영 + 팝오버 즉시 갱신.
const rsvp = async (ev: CalEvent, status: "accepted" | "declined" | "tentative") => {
try {
const updated = await calendarApi.rsvp(ev.id, status);
setPop((p) => (p && p.ev.id === ev.id ? { ...p, ev: updated } : p));
load();
} catch (e) {
alert({
title: "응답 실패",
message: e instanceof Error ? e.message : "응답을 보내지 못했어요",
});
}
};
const nav = (dir: number) => {
if (view === "month") setRefDate(addMonths(ref, dir));
else if (view === "day") {
const nd = addDays(sel, dir);
setSelDate(nd);
setRefDate(nd);
} else setRefDate(addDays(ref, dir * 7));
};
const goToday = () => {
setRefDate(today);
setSelDate(today);
};
const pickDay = (iso: string) => {
setSelDate(iso);
setRefDate(iso);
setView("day");
};
const ws = weekDates[0];
const we = weekDates[6];
const title =
view === "month"
? `${yearNum(ref)}${monthNum(ref)}`
: view === "day"
? `${monthNum(sel)}${dayNum(sel)}`
: monthNum(ws) === monthNum(we)
? `${monthNum(ws)}${dayNum(ws)} ${dayNum(we)}`
: `${monthNum(ws)}${dayNum(ws)} ${monthNum(we)}${dayNum(we)}`;
return (
<div className="calpage" ref={shellRef}>
<div className="twork">
<aside className="subnav">
<div className="sn-title"></div>
<button className="sn-new" onClick={() => setEditor({ ev: null })}>
<Icon name="plus" />
</button>
<MiniCal
monthRef={ref}
sel={sel}
today={today}
weekdays={bundle.weekdays}
eventsByDate={countByDate(events)}
onSel={pickDay}
onMonth={(dir) => setRefDate(addMonths(ref, dir))}
/>
<div className="sn-label"> </div>
<CalToggleList
calendars={bundle.calendars}
calOn={calOn}
onToggle={(id) => setCalOn((s) => ({ ...s, [id]: s[id] === false }))}
/>
<div className="sn-foot">
<div className="av"></div>
<div className="txt">
<b></b>
<span>Pro </span>
</div>
</div>
</aside>
<main className="cmain">
<div className="chead">
<button className="ch-arrow" aria-label="이전" onClick={() => nav(-1)}>
<Icon name="chev" className="ic flip" />
</button>
<button className="ch-arrow" aria-label="다음" onClick={() => nav(1)}>
<Icon name="chev" />
</button>
<h1 className="ch-title">{title}</h1>
<button className="ch-today" onClick={goToday}>
</button>
<span className="ch-spacer" />
<button
className={"ch-focus" + (focusOn ? " on" : "")}
onClick={() => setFocusOn((v) => !v)}
aria-pressed={focusOn}
>
<Icon name="spark" />
</button>
<div className="view-seg" role="tablist">
{VIEWS.map((v) => (
<button
key={v.id}
role="tab"
aria-selected={view === v.id}
className={view === v.id ? "on" : ""}
onClick={() => setView(v.id)}
>
{v.label}
</button>
))}
</div>
</div>
{view === "week" && (
<WeekView
events={events}
focusBlocks={focusBlocks}
calMap={calMap}
weekDates={weekDates}
weekdays={bundle.weekdays}
todayDate={today}
onOpen={openEv}
onSlotClick={openSlot}
onCommit={moveEvent}
/>
)}
{view === "month" && (
<MonthView
events={events}
calMap={calMap}
monthRef={ref}
weekdays={bundle.weekdays}
todayDate={today}
onOpen={openEv}
onSelDay={pickDay}
/>
)}
{view === "day" && (
<DayView
events={events}
focusBlocks={focusBlocks}
calMap={calMap}
dayDate={sel}
todayDate={today}
onOpen={openEv}
onMeet={openMeet}
onSlotClick={openSlot}
onCommit={moveEvent}
/>
)}
</main>
</div>
{pop && (
<EvPopover
ev={pop.ev}
x={pop.x}
y={pop.y}
cal={calMap[pop.ev.cal]}
onClose={() => setPop(null)}
onMeet={openMeet}
onEdit={(ev) => {
setPop(null);
setEditor({ ev });
}}
onDelete={deleteEvent}
onAddToTasks={addToTasks}
onRsvp={rsvp}
/>
)}
{meetEv && (
<MeetDrawer ev={meetEv} cal={calMap[meetEv.cal]} onClose={() => setMeetEv(null)} />
)}
{editor && (
<EvEditor
calendars={bundle.calendars}
initial={editor.ev}
defaultDate={editor.date || sel}
defaultTime={editor.time}
onClose={() => setEditor(null)}
onSaved={afterWrite}
onDeleted={afterWrite}
/>
)}
</div>
);
}