a tool for shared writing and social publishing
1import { useEntity, useReplicache } from "src/replicache";
2import { BlockProps, BlockLayout } from "./Block";
3import { ChevronProps, DayPicker } from "react-day-picker";
4import { Popover } from "components/Popover";
5import { useEffect, useMemo, useState } from "react";
6import { useEntitySetContext } from "components/EntitySetProvider";
7import { useUIState } from "src/useUIState";
8import { setHours, setMinutes } from "date-fns";
9import { Separator } from "react-aria-components";
10import { Checkbox } from "components/Checkbox";
11import { useHasPageLoaded } from "components/InitialPageLoadProvider";
12import { useSpring, animated } from "@react-spring/web";
13import { ArrowRightTiny } from "components/Icons/ArrowRightTiny";
14import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
15
16export function DateTimeBlock(props: BlockProps) {
17 const [isClient, setIsClient] = useState(false);
18 let initialPageLoad = useHasPageLoaded();
19
20 useEffect(() => {
21 setIsClient(true);
22 }, []);
23
24 if (!isClient && !initialPageLoad)
25 return (
26 <div
27 className={`flex flex-row gap-2 group/date w-64 z-1 border border-transparent`}
28 >
29 <BlockCalendarSmall className="text-tertiary" />
30 </div>
31 );
32
33 return <BaseDateTimeBlock {...props} initalLoad={initialPageLoad} />;
34}
35
36export function BaseDateTimeBlock(
37 props: BlockProps & { initalLoad?: boolean },
38) {
39 let { rep } = useReplicache();
40 let { permissions } = useEntitySetContext();
41 let dateFact = useEntity(props.entityID, "block/date-time");
42 let selectedDate = useMemo(() => {
43 if (!dateFact) return new Date();
44 let d = new Date(dateFact.data.value);
45 return d;
46 }, [dateFact]);
47
48 const [timeValue, setTimeValue] = useState<string>(
49 () =>
50 `${selectedDate.getHours().toString().padStart(2, "0")}:${selectedDate.getMinutes().toString().padStart(2, "0")}`,
51 );
52
53 let isSelected = useUIState((s) =>
54 s.selectedBlocks.find((b) => b.value === props.entityID),
55 );
56
57 let isLocked = !!useEntity(props.entityID, "block/is-locked")?.data.value;
58 let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value;
59
60 const handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
61 const time = e.target.value;
62 setTimeValue(time);
63 if (!dateFact) {
64 return;
65 }
66 const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10));
67 const newSelectedDate = setHours(setMinutes(selectedDate, minutes), hours);
68 rep?.mutate.assertFact({
69 entity: props.entityID,
70 data: {
71 type: "date-time",
72 value: newSelectedDate.toISOString(),
73 dateOnly: dateFact?.data.dateOnly,
74 originalTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
75 },
76 attribute: "block/date-time",
77 });
78 };
79
80 const handleDaySelect = (date: Date | undefined) => {
81 if (!timeValue || !date) {
82 if (date)
83 rep?.mutate.assertFact({
84 entity: props.entityID,
85 data: {
86 originalTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
87 type: "date-time",
88 value: date.toISOString(),
89 dateOnly: dateFact?.data.dateOnly,
90 },
91 attribute: "block/date-time",
92 });
93 return;
94 }
95 const [hours, minutes] = timeValue
96 .split(":")
97 .map((str) => parseInt(str, 10));
98 const newDate = new Date(
99 date.getFullYear(),
100 date.getMonth(),
101 date.getDate(),
102 hours,
103 minutes,
104 );
105
106 rep?.mutate.assertFact({
107 entity: props.entityID,
108 data: {
109 type: "date-time",
110 value: newDate.toISOString(),
111
112 originalTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
113 dateOnly: dateFact?.data.dateOnly,
114 },
115 attribute: "block/date-time",
116 });
117 };
118
119 return (
120 <Popover
121 disabled={isLocked || !permissions.write}
122 className="w-64 z-10 px-2!"
123 trigger={
124 <BlockLayout
125 isSelected={!!isSelected}
126 className={`flex flex-row gap-2 group/date w-64 z-1 border-transparent!
127 ${alignment === "center" ? "justify-center" : alignment === "right" ? "justify-end" : "justify-start"}
128 `}
129 >
130 <BlockCalendarSmall className="text-tertiary" />
131 <FadeIn
132 active={props.initalLoad === undefined ? true : props.initalLoad}
133 >
134 {dateFact ? (
135 <div
136 className={`font-bold
137 ${!permissions.write || isLocked ? "" : "group-hover/date:underline"}
138 `}
139 >
140 {selectedDate.toLocaleDateString(undefined, {
141 month: "short",
142 year:
143 new Date().getFullYear() !== selectedDate.getFullYear()
144 ? "numeric"
145 : undefined,
146 day: "numeric",
147 })}{" "}
148 {!dateFact.data.dateOnly ? (
149 <span>
150 |{" "}
151 {selectedDate.toLocaleTimeString([], {
152 hour: "numeric",
153 minute: "numeric",
154 })}
155 </span>
156 ) : null}
157 </div>
158 ) : (
159 <div
160 className={`italic text-tertiary text-left group-hover/date:underline`}
161 >
162 {permissions.write ? "add a date and time..." : "TBD..."}
163 </div>
164 )}
165 </FadeIn>
166 </BlockLayout>
167 }
168 >
169 <div className="flex flex-col gap-3 ">
170 <DayPicker
171 components={{
172 Chevron: (props: ChevronProps) => <CustomChevron {...props} />,
173 }}
174 classNames={{
175 months: "relative",
176 month_caption:
177 "font-bold text-center w-full bg-border-light mb-2 py-1 rounded-md",
178 button_next:
179 "absolute right-0 top-1 p-1 text-secondary hover:text-accent-contrast flex align-center",
180 button_previous:
181 "absolute left-0 top-1 p-1 text-secondary hover:text-accent-contrast rotate-180 flex align-center ",
182 chevron: "text-inherit",
183 month_grid: "w-full table-fixed",
184 weekdays: "text-secondary text-sm",
185 selected: "bg-accent-1! text-accent-2 rounded-md font-bold",
186
187 day: "h-[34px] text-center rounded-md sm:hover:bg-border-light",
188 outside: "text-border",
189 today: "font-bold",
190 }}
191 mode="single"
192 selected={dateFact ? selectedDate : undefined}
193 onSelect={handleDaySelect}
194 />
195 <Separator className="border-border" />
196 <div className="flex gap-4 pb-1 items-center">
197 <Checkbox
198 checked={!!dateFact?.data.dateOnly}
199 onChange={(e) => {
200 rep?.mutate.assertFact({
201 entity: props.entityID,
202 data: {
203 type: "date-time",
204 value: dateFact?.data.value || new Date().toISOString(),
205 originalTimezone:
206 dateFact?.data.originalTimezone ||
207 Intl.DateTimeFormat().resolvedOptions().timeZone,
208 dateOnly: e.currentTarget.checked,
209 },
210 attribute: "block/date-time",
211 });
212 }}
213 >
214 All day
215 </Checkbox>
216 <input
217 disabled={dateFact?.data.dateOnly}
218 type="time"
219 value={timeValue}
220 onChange={handleTimeChange}
221 className="dateBlockTimeInput input-with-border bg-bg-page text-primary w-full "
222 />
223 </div>
224 </div>
225 </Popover>
226 );
227}
228
229let FadeIn = (props: { children: React.ReactNode; active: boolean }) => {
230 let spring = useSpring({ opacity: props.active ? 1 : 0 });
231 return <animated.div style={spring}>{props.children}</animated.div>;
232};
233
234const CustomChevron = (props: ChevronProps) => {
235 return (
236 <div {...props} className="w-full pointer-events-none">
237 <ArrowRightTiny />
238 </div>
239 );
240};