From c1467ef24da7986bdfe008f8b9e597d42fad8725 Mon Sep 17 00:00:00 2001 From: Clas Wen Date: Fri, 30 May 2025 22:27:40 +0800 Subject: [PATCH] save --- src/pages/calendar/Schedule.tsx | 230 +++++++++++++++++++++++++++----- src/pages/join-us.astro | 2 +- src/styles/global.css | 4 +- 3 files changed, 202 insertions(+), 34 deletions(-) diff --git a/src/pages/calendar/Schedule.tsx b/src/pages/calendar/Schedule.tsx index d4ec55d..ed674ac 100644 --- a/src/pages/calendar/Schedule.tsx +++ b/src/pages/calendar/Schedule.tsx @@ -1,9 +1,13 @@ import ICAL from "ical.js" import dayjs from "dayjs" -import { useEffect, useState } from "react" -import { Calendar } from "@heroui/react" +import { useEffect, useMemo, useRef, useState } from "react" +import { Alert, Button, Calendar, Link, Spinner } from "@heroui/react" import { today, getLocalTimeZone } from "@internationalized/date" +import "dayjs/locale/zh-cn" + +dayjs.locale("zh-cn") + type ScheduleEvent = { start: Date end: Date @@ -18,27 +22,51 @@ const parseCal = async (): Promise => { return new ICAL.Component(jcalData) } +const extractScheduleEventsInRange = ( + icalComp: ICAL.Component, + rangeStart: ICAL.Time, + rangeEnd: ICAL.Time, +): ScheduleEvent[] => { + const vevents = icalComp.getAllSubcomponents("vevent") + return vevents + .flatMap((vevent) => { + const event = new ICAL.Event(vevent) + if (!event.isRecurring()) { + if (event.startDate.compare(rangeEnd) > 0 || event.endDate.compare(rangeStart) < 0) return [] + return [{ + start: event.startDate.toJSDate(), + end: event.endDate.toJSDate(), + summary: event.summary, + description: event.description, + recurrenceId: undefined, + }] + } + return expandEventOccurrences(event, rangeStart, rangeEnd) + }) + .sort((a, b) => b.start.getTime() - a.start.getTime()) +} + const expandEventOccurrences = ( event: ICAL.Event, + rangeStart: ICAL.Time, rangeEnd: ICAL.Time, ): ScheduleEvent[] => { const occurrences: ScheduleEvent[] = [] const iterator = event.iterator() let next: ICAL.Time | null = null - while ((next = iterator.next())) { - if (next.compare(event.startDate) < 0) continue + if (!next) break if (next.compare(rangeEnd) > 0) break + if (next.compare(rangeStart) < 0) continue const details = event.getOccurrenceDetails(next) occurrences.push({ start: details.startDate.toJSDate(), end: details.endDate.toJSDate(), summary: event.summary, description: event.description, - recurrenceId: event.recurrenceId, + recurrenceId: next.toString(), }) } - return occurrences } @@ -67,9 +95,9 @@ const formatTimePair = (s: Date, e: Date): string => { const start = dayjs(s) const end = dayjs(e) if (start.isSame(end, "day")) { - return `${start.format("YYYY-MM-DD HH:mm")} - ${end.format("HH:mm")}` + return `${start.format("HH:mm")} - ${end.format("HH:mm")}` } - return `${start.format("YYYY-MM-DD")} - ${end.format("YYYY-MM-DD")}` + return `${start.format("MM.DD")} - ${end.format("MM.DD")}` } export default function Schedule() { @@ -77,38 +105,178 @@ export default function Schedule() { const defaultDate = today(getLocalTimeZone()) const [focusedDate, setFocusedDate] = useState(defaultDate) + const dateRefs = useRef>({}) + const calendarRef = useRef(null) + const [loading, setLoading] = useState(true) useEffect(() => { + const rangeStart = ICAL.Time.fromJSDate(dayjs(focusedDate.toDate(getLocalTimeZone())).startOf("month").toDate()) + const rangeEnd = ICAL.Time.fromJSDate(dayjs(focusedDate.toDate(getLocalTimeZone())).endOf("month").toDate()) + if (!calendarRef.current) { + setLoading(true) + parseCal().then((icalComp) => { + calendarRef.current = icalComp + const events = extractScheduleEventsInRange( + icalComp, + rangeStart, + rangeEnd, + ) + setScheduledEvents(events) + setLoading(false) + }) + } + else { + const events = extractScheduleEventsInRange( + calendarRef.current, + rangeStart, + rangeEnd, + ) + setScheduledEvents(events) + } + }, [focusedDate]) + + useEffect(() => { + setLoading(true) parseCal().then((icalComp) => { setScheduledEvents(extractScheduleEvents(icalComp)) + setLoading(false) }) }, []) + const groupedEvents = useMemo(() => { + const grouped = new Map() + scheduledEvents.forEach((event) => { + const dateKey = dayjs(event.start).format("YYYY-MM-DD") + if (!grouped.has(dateKey)) { + grouped.set(dateKey, []) + } + grouped.get(dateKey)!.push(event) + }) + + // 可选:对每个日期内部事件排序(按开始时间升序) + for (const events of grouped.values()) { + events.sort((a, b) => a.start.getTime() - b.start.getTime()) + } + + // 可选:按照日期从早到晚排序 + return Array.from(grouped.entries()).sort(([a], [b]) => a.localeCompare(b)) + }, [scheduledEvents]) + + const isUserChangeRef = useRef(false) + + useEffect(() => { + if (!isUserChangeRef.current) return + isUserChangeRef.current = false + + const dateKey = dayjs(focusedDate.toDate(getLocalTimeZone())).format("YYYY-MM-DD") + const target = dateRefs.current[dateKey] + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "start" }) + } + else { + // to nearest date if not found + const dates = Object.keys(dateRefs.current).map(d => dayjs(d)) + const closestDate = dates.reduce((prev, curr) => { + return Math.abs(curr.diff(focusedDate.toDate(getLocalTimeZone()), "day")) < Math.abs(prev.diff(focusedDate.toDate(getLocalTimeZone()), "day")) ? curr : prev + }, dates[0]) + const closestKey = closestDate.format("YYYY-MM-DD") + const closestTarget = dateRefs.current[closestKey] + if (closestTarget) { + closestTarget.scrollIntoView({ behavior: "smooth", block: "start" }) + } + } + }, [focusedDate, groupedEvents]) + return ( -
-
日程
-
-
- {scheduledEvents.map((event, index) => ( -
-
-
{event.summary}
- { formatTimePair(event.start, event.end) } -
-

{event.description}

-

{event.recurrenceId}

-
- ))} -
-
- +
+
+
日程
+ console.log(e)} + href="webcal://ical.nbtca.space" + target="_blank" + > + 订阅 + + )} /> +
+
+
+ { + dayjs(focusedDate.toDate()).format("MMMM YYYY") + } +
+
+ {loading && ( +
+ +
+ )} + {groupedEvents.length === 0 && !loading && ( +
本月暂无日程
+ )} + { + groupedEvents.map(([date, events]) => ( +
dateRefs.current[date] = el} + className="mb-6" + > +
+ { dayjs(date).format("MM.DD") } + { dayjs(date).format("ddd") } +
+ {events.map((event, index) => ( +
+
+
{event.summary}
+ {event.recurrenceId && ( + // 重复 + + + + + )} +
+ {formatTimePair(event.start, event.end)} + { + event.description && ( +

{event.description}

+ ) + } +
+ ))} +
+ )) + } +
+
+
+ { + isUserChangeRef.current = true + setFocusedDate(v) + }} + /> +
+
+
-
-
+
+ ) } diff --git a/src/pages/join-us.astro b/src/pages/join-us.astro index 41f13da..148c13f 100644 --- a/src/pages/join-us.astro +++ b/src/pages/join-us.astro @@ -121,7 +121,7 @@ import logoAnimated from "./_assets/nbtca.gif"; -webkit-font-smoothing: antialiased; direction: ltr; font-weight: 400; - font-size: 17px; + font-size: 16px; line-height: 1.82353; --body-container-width: 645px; text-align: center; diff --git a/src/styles/global.css b/src/styles/global.css index e05d037..c89fa63 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -149,12 +149,12 @@ button:disabled { html { font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Icons", "Helvetica Neue", "Helvetica", "Arial", sans-serif; - font-size: 106.25%; + /* font-size: 106.25%; */ quotes: "“" "”"; } body { - font-size: 17px; + /* font-size: 17px; */ line-height: 1.47059; font-weight: 400; letter-spacing: -0.022em;