a tool for shared writing and social publishing

Feature/date block (#97)

* add basic date block

* add some styling

* styled the date picked some

* small tweaks

* fixed issue with custom chevrons not being clickable

* add unstyled time picker

* stled time input, added z-index

* added a max height to the popover so that it respects the collision boundary, unified some of the popover styles... but not all lol

* more popover optimizations, made it possible for no date to be selected in date picker to accomodate empty state

* use the proper button for help button

---------

Co-authored-by: celine <celine@hyperlink.academy>

authored by awarm.space

celine and committed by
GitHub
61e6ac2e fa89add3

+260 -13
+10
app/globals.css
··· 159 159 background-color: transparent; 160 160 } 161 161 162 + .input-border { 163 + @apply border; 164 + @apply border-border; 165 + @apply rounded-md; 166 + @apply px-1; 167 + @apply py-0.5; 168 + @apply hover:border-tertiary; 169 + @apply active:border-tertiary; 170 + } 171 + 162 172 .block-border { 163 173 @apply border; 164 174 @apply border-border-light;
+6 -2
components/Blocks/BlockCommandBar.tsx
··· 109 109 collisionPadding={16} 110 110 ref={ref} 111 111 onOpenAutoFocus={(e) => e.preventDefault()} 112 - className={`commandMenuContent group/cmd-menu z-20 h-[292px] w-[264px] flex data-[side=top]:items-end items-start`} 112 + className={` 113 + commandMenuContent group/cmd-menu 114 + z-20 w-[264px] 115 + flex data-[side=top]:items-end items-start 116 + `} 113 117 > 114 118 <NestedCardThemeProvider> 115 - <div className="commandMenuResults w-full flex flex-col group-data-[side=top]/cmd-menu:flex-col-reverse bg-bg-page py-1 gap-0.5 border border-border rounded-md shadow-md"> 119 + <div className="commandMenuResults w-full max-h-[var(--radix-popover-content-available-height)] overflow-scroll no-scrollbar flex flex-col group-data-[side=top]/cmd-menu:flex-col-reverse bg-bg-page py-1 gap-0.5 border border-border rounded-md shadow-md"> 116 120 {commandResults.length === 0 ? ( 117 121 <div className="w-full text-tertiary text-center italic py-2 px-2 "> 118 122 No blocks found
+12
components/Blocks/BlockCommands.tsx
··· 11 11 ParagraphSmall, 12 12 LinkSmall, 13 13 BlockEmbedSmall, 14 + BlockCalendarSmall, 14 15 } from "components/Icons"; 15 16 import { generateKeyBetween } from "fractional-indexing"; 16 17 import { focusPage } from "components/Pages"; ··· 193 194 createBlockWithType(rep, props, "mailbox"); 194 195 }, 195 196 }, 197 + 198 + // EVENT STUFF 199 + 200 + { 201 + name: "Date and Time", 202 + icon: <BlockCalendarSmall />, 203 + type: "block", 204 + onSelect: (rep, props) => createBlockWithType(rep, props, "datetime"), 205 + }, 206 + 207 + // PAGE TYPES 196 208 197 209 { 198 210 name: "New Page",
+151
components/Blocks/DateTimeBlock.tsx
··· 1 + import { useEntity, useReplicache } from "src/replicache"; 2 + import { BlockProps } from "./Block"; 3 + import { ChevronProps, DayPicker } from "react-day-picker"; 4 + import { Popover } from "components/Popover"; 5 + import { useMemo, useState } from "react"; 6 + import { ArrowRightTiny, BlockCalendarSmall } from "components/Icons"; 7 + import { useEntitySetContext } from "components/EntitySetProvider"; 8 + import { useUIState } from "src/useUIState"; 9 + import { setHours, setMinutes } from "date-fns"; 10 + import { Separator } from "react-aria-components"; 11 + 12 + export function DateTimeBlock(props: BlockProps) { 13 + let { rep } = useReplicache(); 14 + let { permissions } = useEntitySetContext(); 15 + let dateFact = useEntity(props.entityID, "block/date-time"); 16 + 17 + const [timeValue, setTimeValue] = useState<string>("00:00"); 18 + let selectedDate = useMemo(() => { 19 + if (!dateFact) return new Date(); 20 + return new Date(dateFact.data.value); 21 + }, [dateFact]); 22 + 23 + let isSelected = useUIState((s) => 24 + s.selectedBlocks.find((b) => b.value === props.entityID), 25 + ); 26 + 27 + // let isLocked = useEntity(props.entityID, "block/locked")?.data.value; 28 + 29 + const handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { 30 + const time = e.target.value; 31 + if (!dateFact) { 32 + setTimeValue(time); 33 + return; 34 + } 35 + const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); 36 + const newSelectedDate = setHours(setMinutes(selectedDate, minutes), hours); 37 + rep?.mutate.assertFact({ 38 + entity: props.entityID, 39 + data: { type: "string", value: newSelectedDate.toISOString() }, 40 + attribute: "block/date-time", 41 + }); 42 + setTimeValue(time); 43 + }; 44 + 45 + const handleDaySelect = (date: Date | undefined) => { 46 + if (!timeValue || !date) { 47 + if (date) 48 + rep?.mutate.assertFact({ 49 + entity: props.entityID, 50 + data: { type: "string", value: date.toISOString() }, 51 + attribute: "block/date-time", 52 + }); 53 + return; 54 + } 55 + const [hours, minutes] = timeValue 56 + .split(":") 57 + .map((str) => parseInt(str, 10)); 58 + const newDate = new Date( 59 + date.getFullYear(), 60 + date.getMonth(), 61 + date.getDate(), 62 + hours, 63 + minutes, 64 + ); 65 + 66 + rep?.mutate.assertFact({ 67 + entity: props.entityID, 68 + data: { type: "string", value: newDate.toISOString() }, 69 + attribute: "block/date-time", 70 + }); 71 + }; 72 + 73 + return ( 74 + <Popover 75 + className="w-64 z-10 !px-2" 76 + trigger={ 77 + <div 78 + className={`flex flex-row gap-2 group/date w-64 z-[1] 79 + ${isSelected ? "block-border-selected !border-transparent" : "border border-transparent"} 80 + ${!permissions.write ? "pointer-events-none" : ""} 81 + `} 82 + > 83 + <BlockCalendarSmall className="text-tertiary" /> 84 + {dateFact ? ( 85 + <div className="group-hover/date:underline font-bold "> 86 + {selectedDate.toLocaleDateString(undefined, { 87 + month: "short", 88 + year: "numeric", 89 + day: "numeric", 90 + })}{" "} 91 + |{" "} 92 + {selectedDate.toLocaleTimeString(undefined, { 93 + hour: "numeric", 94 + minute: "numeric", 95 + })} 96 + </div> 97 + ) : ( 98 + <div 99 + className={`italic text-tertiary text-left group-hover/date:underline`} 100 + > 101 + {permissions.write ? "add a date and time..." : "TBD..."} 102 + </div> 103 + )} 104 + </div> 105 + } 106 + > 107 + <div className=" flex flex-col gap-2 "> 108 + <DayPicker 109 + components={{ 110 + Chevron: (props: ChevronProps) => <CustomChevron {...props} />, 111 + }} 112 + classNames={{ 113 + months: "relative", 114 + month_caption: 115 + "font-bold text-center w-full bg-border-light mb-2 py-1 rounded-md", 116 + button_next: 117 + "absolute right-0 top-1 p-1 text-secondary hover:text-accent-contrast flex align-center", 118 + button_previous: 119 + "absolute left-0 top-1 p-1 text-secondary hover:text-accent-contrast rotate-180 flex align-center ", 120 + chevron: "text-inherit", 121 + month_grid: "w-full table-fixed", 122 + weekdays: "text-secondary text-sm", 123 + selected: "bg-accent-1 text-accent-2 rounded-md font-bold ", 124 + 125 + day: "h-[34px] text-center rounded-md sm:hover:bg-border-light", 126 + outside: "text-border", 127 + today: "font-bold", 128 + }} 129 + mode="single" 130 + selected={dateFact ? selectedDate : undefined} 131 + onSelect={handleDaySelect} 132 + /> 133 + <Separator className="border-border" /> 134 + <input 135 + type="time" 136 + value={timeValue} 137 + onChange={handleTimeChange} 138 + className="dateBlockTimeInput input-border w-full mb-1 " 139 + /> 140 + </div> 141 + </Popover> 142 + ); 143 + } 144 + 145 + const CustomChevron = (props: ChevronProps) => { 146 + return ( 147 + <div {...props} className="w-full pointer-events-none"> 148 + <ArrowRightTiny /> 149 + </div> 150 + ); 151 + };
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 119 119 </div> 120 120 )} 121 121 </div> 122 - {props.preview && <PagePreview entityID={pageEntity} />} 122 + {!props.preview && <PagePreview entityID={pageEntity} />} 123 123 </div> 124 124 </> 125 125 </div>
+2 -2
components/Blocks/TextBlock/index.tsx
··· 44 44 import { AddTiny, MoreOptionsTiny } from "components/Icons"; 45 45 46 46 export function TextBlock( 47 - props: BlockProps & { className: string; preview?: boolean }, 47 + props: BlockProps & { className?: string; preview?: boolean }, 48 48 ) { 49 49 let initialized = useInitialPageLoad(); 50 50 let first = props.previousBlock === null; ··· 179 179 ); 180 180 } 181 181 182 - export function BaseTextBlock(props: BlockProps & { className: string }) { 182 + export function BaseTextBlock(props: BlockProps & { className?: string }) { 183 183 const [mount, setMount] = useState<HTMLElement | null>(null); 184 184 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 185 185 let entity_set = useEntitySetContext();
+9 -5
components/HelpPopover.tsx
··· 7 7 import { useEntitySetContext } from "./EntitySetProvider"; 8 8 import Link from "next/link"; 9 9 import { useState } from "react"; 10 + import { HoverButton } from "./Buttons"; 10 11 11 12 export const HelpPopover = () => { 12 13 let entity_set = useEntitySetContext(); 13 14 return entity_set.permissions.write ? ( 14 15 <Popover 15 - className="max-w-xs w-full max-h-[60vh] overflow-y-scroll" 16 + className="max-w-xs w-full" 16 17 trigger={ 17 - <div className="p-1 rounded-full bg-accent-1 text-accent-2"> 18 - <HelpSmall />{" "} 19 - </div> 18 + <HoverButton 19 + icon={<HelpSmall />} 20 + label="About This App" 21 + background="bg-accent-1" 22 + text="text-accent-2" 23 + /> 20 24 } 21 25 > 22 - <div className="flex flex-col text-sm gap-2 text-secondary"> 26 + <div className="flex flex-col text-sm gap-2 text-secondary "> 23 27 <div> 24 28 Welcome to <strong>Leaflet</strong> — a fun, fast, easy-to-share 25 29 document editor.
+26
components/Icons.tsx
··· 144 144 ); 145 145 }; 146 146 147 + export const BlockCalendarSmall = (props: Props) => { 148 + return ( 149 + <svg 150 + width="24" 151 + height="24" 152 + viewBox="0 0 24 24" 153 + fill="none" 154 + xmlns="http://www.w3.org/2000/svg" 155 + {...props} 156 + > 157 + <path 158 + d="M6.18047 19.3H3.5C3.22386 19.3 3 19.0762 3 18.8V5.80005C3 5.52391 3.22386 5.30005 3.5 5.30005H7.5M6.18047 19.3C6.62026 20.2647 7.06178 20.8513 7.26475 21.0943C7.3377 21.1817 7.44011 21.2319 7.55351 21.2414C8.31746 21.3056 11.5817 21.4897 16.9 20.5617C19.1949 20.1613 20.7153 19.0931 21.4612 18.441C21.7315 18.2046 21.6222 17.7792 21.3027 17.6153C20.3079 17.105 18.6 15.8525 18.6 13.2678V7.79888C18.6 7.52274 18.3761 7.30005 18.1 7.30005H17.5M6.18047 19.3C5.58866 18.0019 5 16.0192 5 13.2678V7.79869C5 7.52255 5.22386 7.30005 5.5 7.30005H7M11.75 5.30005H13.5M11.8 7.30005H13" 159 + stroke="currentColor" 160 + strokeWidth="1.5" 161 + strokeLinecap="round" 162 + /> 163 + <path 164 + d="M8 11.8H12.1313C15.5 11.8 16.5 11.5 16.5 11.5M7.95869 9C9.01704 9 9.875 7.65685 9.875 6C9.875 4.34315 9.01704 3 7.95869 3C7.55649 3 7.18323 3.19398 6.875 3.52543M14.0837 9C15.142 9 16 7.65685 16 6C16 4.34315 15.142 3 14.0837 3C13.6815 3 13.3082 3.19398 13 3.52543" 165 + stroke="currentColor" 166 + strokeWidth="1.25" 167 + strokeLinecap="round" 168 + /> 169 + </svg> 170 + ); 171 + }; 172 + 147 173 export const BlockCanvasPageSmall = (props: Props) => { 148 174 return ( 149 175 <svg
+3 -2
components/Popover.tsx
··· 10 10 background?: string; 11 11 border?: string; 12 12 className?: string; 13 + open?: boolean; 13 14 }) => { 14 15 return ( 15 - <RadixPopover.Root> 16 + <RadixPopover.Root open={props.open}> 16 17 <RadixPopover.Trigger>{props.trigger}</RadixPopover.Trigger> 17 18 <RadixPopover.Portal> 18 19 <NestedCardThemeProvider> 19 20 <RadixPopover.Content 20 - className={`z-20 bg-bg-page border border-border rounded-md px-3 py-2 ${props.className}`} 21 + className={`z-20 bg-bg-page border border-border rounded-md px-3 py-2 max-h-[var(--radix-popover-content-available-height)] overflow-y-scroll no-scrollbar shadow-md ${props.className}`} 21 22 align={props.align ? props.align : "center"} 22 23 sideOffset={4} 23 24 collisionPadding={16}
+34
package-lock.json
··· 35 35 "prosemirror-state": "^1.4.3", 36 36 "react": "^18.3.1", 37 37 "react-aria-components": "^1.2.1", 38 + "react-day-picker": "^9.3.0", 38 39 "react-dom": "^18.3.1", 39 40 "react-use-measure": "^2.1.1", 40 41 "rehype-parse": "^9.0.0", ··· 244 245 "@jridgewell/resolve-uri": "^3.0.3", 245 246 "@jridgewell/sourcemap-codec": "^1.4.10" 246 247 } 248 + }, 249 + "node_modules/@date-fns/tz": { 250 + "version": "1.2.0", 251 + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", 252 + "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==" 247 253 }, 248 254 "node_modules/@esbuild-kit/core-utils": { 249 255 "version": "3.3.2", ··· 7206 7212 "url": "https://github.com/sponsors/ljharb" 7207 7213 } 7208 7214 }, 7215 + "node_modules/date-fns": { 7216 + "version": "4.1.0", 7217 + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", 7218 + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", 7219 + "funding": { 7220 + "type": "github", 7221 + "url": "https://github.com/sponsors/kossnocorp" 7222 + } 7223 + }, 7209 7224 "node_modules/debounce": { 7210 7225 "version": "1.2.1", 7211 7226 "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", ··· 11974 11989 "peerDependencies": { 11975 11990 "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", 11976 11991 "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" 11992 + } 11993 + }, 11994 + "node_modules/react-day-picker": { 11995 + "version": "9.3.0", 11996 + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.3.0.tgz", 11997 + "integrity": "sha512-xXgZISTXlwQ1Igt4cBttXF+aK1Xvd00azcGVY74PNCAe8PxtULFVWGT1UfdavFiVScF04dyV8QcybKZAw570QQ==", 11998 + "dependencies": { 11999 + "@date-fns/tz": "^1.1.2", 12000 + "date-fns": "^4.1.0" 12001 + }, 12002 + "engines": { 12003 + "node": ">=18" 12004 + }, 12005 + "funding": { 12006 + "type": "individual", 12007 + "url": "https://github.com/sponsors/gpbl" 12008 + }, 12009 + "peerDependencies": { 12010 + "react": ">=16.8.0" 11977 12011 } 11978 12012 }, 11979 12013 "node_modules/react-dom": {
+1
package.json
··· 37 37 "prosemirror-state": "^1.4.3", 38 38 "react": "^18.3.1", 39 39 "react-aria-components": "^1.2.1", 40 + "react-day-picker": "^9.3.0", 40 41 "react-dom": "^18.3.1", 41 42 "react-use-measure": "^2.1.1", 42 43 "rehype-parse": "^9.0.0",
+5 -1
src/replicache/attributes.ts
··· 52 52 type: "text-alignment-type-union", 53 53 cardinality: "one", 54 54 }, 55 + "block/date-time": { 56 + type: "string", 57 + cardinality: "one", 58 + }, 55 59 "block/text": { 56 60 type: "text", 57 61 cardinality: "one", ··· 222 226 "block-type-union": { 223 227 type: "block-type-union"; 224 228 value: 229 + | "datetime" 225 230 | "text" 226 231 | "image" 227 232 | "card" 228 233 | "heading" 229 234 | "link" 230 235 | "mailbox" 231 - | "collection" 232 236 | "embed"; 233 237 }; 234 238 "canvas-pattern-union": {