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