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 background-color: transparent; 160 } 161 162 .block-border { 163 @apply border; 164 @apply border-border-light;
··· 159 background-color: transparent; 160 } 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 + 172 .block-border { 173 @apply border; 174 @apply border-border-light;
+6 -2
components/Blocks/BlockCommandBar.tsx
··· 109 collisionPadding={16} 110 ref={ref} 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`} 113 > 114 <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"> 116 {commandResults.length === 0 ? ( 117 <div className="w-full text-tertiary text-center italic py-2 px-2 "> 118 No blocks found
··· 109 collisionPadding={16} 110 ref={ref} 111 onOpenAutoFocus={(e) => e.preventDefault()} 112 + className={` 113 + commandMenuContent group/cmd-menu 114 + z-20 w-[264px] 115 + flex data-[side=top]:items-end items-start 116 + `} 117 > 118 <NestedCardThemeProvider> 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"> 120 {commandResults.length === 0 ? ( 121 <div className="w-full text-tertiary text-center italic py-2 px-2 "> 122 No blocks found
+12
components/Blocks/BlockCommands.tsx
··· 11 ParagraphSmall, 12 LinkSmall, 13 BlockEmbedSmall, 14 } from "components/Icons"; 15 import { generateKeyBetween } from "fractional-indexing"; 16 import { focusPage } from "components/Pages"; ··· 193 createBlockWithType(rep, props, "mailbox"); 194 }, 195 }, 196 197 { 198 name: "New Page",
··· 11 ParagraphSmall, 12 LinkSmall, 13 BlockEmbedSmall, 14 + BlockCalendarSmall, 15 } from "components/Icons"; 16 import { generateKeyBetween } from "fractional-indexing"; 17 import { focusPage } from "components/Pages"; ··· 194 createBlockWithType(rep, props, "mailbox"); 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 208 209 { 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 </div> 120 )} 121 </div> 122 - {props.preview && <PagePreview entityID={pageEntity} />} 123 </div> 124 </> 125 </div>
··· 119 </div> 120 )} 121 </div> 122 + {!props.preview && <PagePreview entityID={pageEntity} />} 123 </div> 124 </> 125 </div>
+2 -2
components/Blocks/TextBlock/index.tsx
··· 44 import { AddTiny, MoreOptionsTiny } from "components/Icons"; 45 46 export function TextBlock( 47 - props: BlockProps & { className: string; preview?: boolean }, 48 ) { 49 let initialized = useInitialPageLoad(); 50 let first = props.previousBlock === null; ··· 179 ); 180 } 181 182 - export function BaseTextBlock(props: BlockProps & { className: string }) { 183 const [mount, setMount] = useState<HTMLElement | null>(null); 184 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 185 let entity_set = useEntitySetContext();
··· 44 import { AddTiny, MoreOptionsTiny } from "components/Icons"; 45 46 export function TextBlock( 47 + props: BlockProps & { className?: string; preview?: boolean }, 48 ) { 49 let initialized = useInitialPageLoad(); 50 let first = props.previousBlock === null; ··· 179 ); 180 } 181 182 + export function BaseTextBlock(props: BlockProps & { className?: string }) { 183 const [mount, setMount] = useState<HTMLElement | null>(null); 184 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 185 let entity_set = useEntitySetContext();
+9 -5
components/HelpPopover.tsx
··· 7 import { useEntitySetContext } from "./EntitySetProvider"; 8 import Link from "next/link"; 9 import { useState } from "react"; 10 11 export const HelpPopover = () => { 12 let entity_set = useEntitySetContext(); 13 return entity_set.permissions.write ? ( 14 <Popover 15 - className="max-w-xs w-full max-h-[60vh] overflow-y-scroll" 16 trigger={ 17 - <div className="p-1 rounded-full bg-accent-1 text-accent-2"> 18 - <HelpSmall />{" "} 19 - </div> 20 } 21 > 22 - <div className="flex flex-col text-sm gap-2 text-secondary"> 23 <div> 24 Welcome to <strong>Leaflet</strong> — a fun, fast, easy-to-share 25 document editor.
··· 7 import { useEntitySetContext } from "./EntitySetProvider"; 8 import Link from "next/link"; 9 import { useState } from "react"; 10 + import { HoverButton } from "./Buttons"; 11 12 export const HelpPopover = () => { 13 let entity_set = useEntitySetContext(); 14 return entity_set.permissions.write ? ( 15 <Popover 16 + className="max-w-xs w-full" 17 trigger={ 18 + <HoverButton 19 + icon={<HelpSmall />} 20 + label="About This App" 21 + background="bg-accent-1" 22 + text="text-accent-2" 23 + /> 24 } 25 > 26 + <div className="flex flex-col text-sm gap-2 text-secondary "> 27 <div> 28 Welcome to <strong>Leaflet</strong> — a fun, fast, easy-to-share 29 document editor.
+26
components/Icons.tsx
··· 144 ); 145 }; 146 147 export const BlockCanvasPageSmall = (props: Props) => { 148 return ( 149 <svg
··· 144 ); 145 }; 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 + 173 export const BlockCanvasPageSmall = (props: Props) => { 174 return ( 175 <svg
+3 -2
components/Popover.tsx
··· 10 background?: string; 11 border?: string; 12 className?: string; 13 }) => { 14 return ( 15 - <RadixPopover.Root> 16 <RadixPopover.Trigger>{props.trigger}</RadixPopover.Trigger> 17 <RadixPopover.Portal> 18 <NestedCardThemeProvider> 19 <RadixPopover.Content 20 - className={`z-20 bg-bg-page border border-border rounded-md px-3 py-2 ${props.className}`} 21 align={props.align ? props.align : "center"} 22 sideOffset={4} 23 collisionPadding={16}
··· 10 background?: string; 11 border?: string; 12 className?: string; 13 + open?: boolean; 14 }) => { 15 return ( 16 + <RadixPopover.Root open={props.open}> 17 <RadixPopover.Trigger>{props.trigger}</RadixPopover.Trigger> 18 <RadixPopover.Portal> 19 <NestedCardThemeProvider> 20 <RadixPopover.Content 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}`} 22 align={props.align ? props.align : "center"} 23 sideOffset={4} 24 collisionPadding={16}
+34
package-lock.json
··· 35 "prosemirror-state": "^1.4.3", 36 "react": "^18.3.1", 37 "react-aria-components": "^1.2.1", 38 "react-dom": "^18.3.1", 39 "react-use-measure": "^2.1.1", 40 "rehype-parse": "^9.0.0", ··· 244 "@jridgewell/resolve-uri": "^3.0.3", 245 "@jridgewell/sourcemap-codec": "^1.4.10" 246 } 247 }, 248 "node_modules/@esbuild-kit/core-utils": { 249 "version": "3.3.2", ··· 7206 "url": "https://github.com/sponsors/ljharb" 7207 } 7208 }, 7209 "node_modules/debounce": { 7210 "version": "1.2.1", 7211 "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", ··· 11974 "peerDependencies": { 11975 "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", 11976 "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" 11977 } 11978 }, 11979 "node_modules/react-dom": {
··· 35 "prosemirror-state": "^1.4.3", 36 "react": "^18.3.1", 37 "react-aria-components": "^1.2.1", 38 + "react-day-picker": "^9.3.0", 39 "react-dom": "^18.3.1", 40 "react-use-measure": "^2.1.1", 41 "rehype-parse": "^9.0.0", ··· 245 "@jridgewell/resolve-uri": "^3.0.3", 246 "@jridgewell/sourcemap-codec": "^1.4.10" 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==" 253 }, 254 "node_modules/@esbuild-kit/core-utils": { 255 "version": "3.3.2", ··· 7212 "url": "https://github.com/sponsors/ljharb" 7213 } 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 + }, 7224 "node_modules/debounce": { 7225 "version": "1.2.1", 7226 "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", ··· 11989 "peerDependencies": { 11990 "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0", 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" 12011 } 12012 }, 12013 "node_modules/react-dom": {
+1
package.json
··· 37 "prosemirror-state": "^1.4.3", 38 "react": "^18.3.1", 39 "react-aria-components": "^1.2.1", 40 "react-dom": "^18.3.1", 41 "react-use-measure": "^2.1.1", 42 "rehype-parse": "^9.0.0",
··· 37 "prosemirror-state": "^1.4.3", 38 "react": "^18.3.1", 39 "react-aria-components": "^1.2.1", 40 + "react-day-picker": "^9.3.0", 41 "react-dom": "^18.3.1", 42 "react-use-measure": "^2.1.1", 43 "rehype-parse": "^9.0.0",
+5 -1
src/replicache/attributes.ts
··· 52 type: "text-alignment-type-union", 53 cardinality: "one", 54 }, 55 "block/text": { 56 type: "text", 57 cardinality: "one", ··· 222 "block-type-union": { 223 type: "block-type-union"; 224 value: 225 | "text" 226 | "image" 227 | "card" 228 | "heading" 229 | "link" 230 | "mailbox" 231 - | "collection" 232 | "embed"; 233 }; 234 "canvas-pattern-union": {
··· 52 type: "text-alignment-type-union", 53 cardinality: "one", 54 }, 55 + "block/date-time": { 56 + type: "string", 57 + cardinality: "one", 58 + }, 59 "block/text": { 60 type: "text", 61 cardinality: "one", ··· 226 "block-type-union": { 227 type: "block-type-union"; 228 value: 229 + | "datetime" 230 | "text" 231 | "image" 232 | "card" 233 | "heading" 234 | "link" 235 | "mailbox" 236 | "embed"; 237 }; 238 "canvas-pattern-union": {