a tool for shared writing and social publishing

Feature/backdate (#258)

* added capability to backdate post on published docs

* componentize Date Picker, add Time Picker, mock up date and time pickers
in publish show and update flow

* wire up backdating posts from the publish flow. still broken from update
button

* wire up updating pubish date on published post

* just bold some stuff

* open calendars to corrent months and times by default

authored by cozylittle.house and committed by GitHub e13306ec 08302114

+297 -52
+7 -4
actions/publishToPublication.ts
··· 66 66 tags, 67 67 cover_image, 68 68 entitiesToDelete, 69 + publishedAt, 69 70 }: { 70 71 root_entity: string; 71 72 publication_uri?: string; ··· 75 76 tags?: string[]; 76 77 cover_image?: string | null; 77 78 entitiesToDelete?: string[]; 79 + publishedAt?: string; 78 80 }): Promise<PublishResult> { 79 81 let identity = await getIdentityData(); 80 82 if (!identity || !identity.atp_did) { ··· 147 149 credentialSession.did!, 148 150 ); 149 151 150 - let existingRecord = 151 - (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 152 + let existingRecord = draft?.documents?.data as 153 + | PubLeafletDocument.Record 154 + | undefined; 152 155 153 156 // Extract theme for standalone documents (not for publications) 154 157 let theme: PubLeafletPublication.Theme | undefined; ··· 174 177 } 175 178 176 179 let record: PubLeafletDocument.Record = { 177 - publishedAt: new Date().toISOString(), 178 - ...existingRecord, 179 180 $type: "pub.leaflet.document", 180 181 author: credentialSession.did!, 181 182 ...(publication_uri && { publication: publication_uri }), ··· 199 200 }; 200 201 } 201 202 }), 203 + publishedAt: 204 + publishedAt || existingRecord?.publishedAt || new Date().toISOString(), 202 205 }; 203 206 204 207 // Keep the same rkey if updating an existing document
+9 -2
app/[leaflet_id]/actions/PublishButton.tsx
··· 24 24 import { DotLoader } from "components/utils/DotLoader"; 25 25 import { PubLeafletPublication } from "lexicons/api"; 26 26 import { useParams, useRouter, useSearchParams } from "next/navigation"; 27 - import { useState, useMemo } from "react"; 27 + import { useState, useMemo, useEffect } from "react"; 28 28 import { useIsMobile } from "src/hooks/isMobile"; 29 29 import { useReplicache, useEntity } from "src/replicache"; 30 30 import { useSubscribe } from "src/replicache/useSubscribe"; ··· 40 40 import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 41 41 import { AddTiny } from "components/Icons/AddTiny"; 42 42 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 43 + import { useLocalPublishedAt } from "components/Pages/Backdater"; 43 44 44 45 export const PublishButton = (props: { entityID: string }) => { 45 46 let { data: pub } = useLeafletPublicationData(); ··· 95 96 tx.get<string | null>("publication_cover_image"), 96 97 ); 97 98 99 + // Get local published at from Replicache (session-only state, not persisted to DB) 100 + let publishedAt = useLocalPublishedAt((s) => 101 + pub?.doc ? s[pub?.doc] : undefined, 102 + ); 103 + 98 104 return ( 99 105 <ActionButton 100 106 primary ··· 111 117 description: currentDescription, 112 118 tags: currentTags, 113 119 cover_image: coverImage, 120 + publishedAt: publishedAt?.toISOString(), 114 121 }); 115 122 setIsLoading(false); 116 123 mutate(); ··· 134 141 135 142 toaster({ 136 143 content: ( 137 - <div> 144 + <div className="font-bold"> 138 145 {pub.doc ? "Updated! " : "Published! "} 139 146 <SpeedyLink className="underline" href={docUrl}> 140 147 See Published Post
+114 -11
app/[leaflet_id]/publish/PublishPost.tsx
··· 23 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 24 import { PubIcon } from "components/ActionBar/Publications"; 25 25 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 26 + import { DatePicker, TimePicker } from "components/DatePicker"; 27 + import { Popover } from "components/Popover"; 28 + import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 29 + import { Separator } from "react-aria-components"; 30 + import { setHours, setMinutes } from "date-fns"; 26 31 27 32 type Props = { 28 33 title: string; ··· 78 83 ); 79 84 let [localTags, setLocalTags] = useState<string[]>([]); 80 85 86 + let [localPublishedAt, setLocalPublishedAt] = useState<Date | undefined>( 87 + undefined, 88 + ); 81 89 // Get cover image from Replicache 82 90 let replicacheCoverImage = useSubscribe(rep, (tx) => 83 91 tx.get<string | null>("publication_cover_image"), 84 92 ); 85 93 86 94 // Use Replicache tags only when we have a draft 87 - const hasDraft = props.hasDraft; 88 - const currentTags = hasDraft 95 + const currentTags = props.hasDraft 89 96 ? Array.isArray(replicacheTags) 90 97 ? replicacheTags 91 98 : [] ··· 93 100 94 101 // Update tags via Replicache mutation or local state depending on context 95 102 const handleTagsChange = async (newTags: string[]) => { 96 - if (hasDraft) { 103 + if (props.hasDraft) { 97 104 await rep?.mutate.updatePublicationDraft({ 98 105 tags: newTags, 99 106 }); ··· 116 123 tags: currentTags, 117 124 cover_image: replicacheCoverImage, 118 125 entitiesToDelete: props.entitiesToDelete, 126 + publishedAt: localPublishedAt?.toISOString() || new Date().toISOString(), 119 127 }); 120 128 121 129 if (!result.success) { ··· 168 176 record={props.record} 169 177 /> 170 178 <hr className="border-border" /> 171 - <ShareOptions 172 - setShareOption={setShareOption} 173 - shareOption={shareOption} 174 - charCount={charCount} 175 - setCharCount={setCharCount} 176 - editorStateRef={editorStateRef} 177 - {...props} 179 + 180 + <BackdateOptions 181 + publishedAt={localPublishedAt} 182 + setPublishedAt={setLocalPublishedAt} 178 183 /> 179 184 <hr className="border-border " /> 185 + 180 186 <div className="flex flex-col gap-2"> 181 187 <h4>Tags</h4> 182 188 <TagSelector ··· 184 190 setSelectedTags={handleTagsChange} 185 191 /> 186 192 </div> 193 + <hr className="border-border" /> 194 + <ShareOptions 195 + setShareOption={setShareOption} 196 + shareOption={shareOption} 197 + charCount={charCount} 198 + setCharCount={setCharCount} 199 + editorStateRef={editorStateRef} 200 + {...props} 201 + /> 187 202 <hr className="border-border mb-2" /> 188 203 189 204 <div className="flex flex-col gap-2"> ··· 219 234 ); 220 235 }; 221 236 237 + const BackdateOptions = (props: { 238 + publishedAt: Date | undefined; 239 + setPublishedAt: (date: Date | undefined) => void; 240 + }) => { 241 + const formattedDate = useLocalizedDate( 242 + props.publishedAt?.toISOString() || "", 243 + { 244 + month: "short", 245 + day: "numeric", 246 + year: "numeric", 247 + hour: "numeric", 248 + minute: "numeric", 249 + hour12: true, 250 + }, 251 + ); 252 + 253 + const [timeValue, setTimeValue] = useState<string>(() => { 254 + const date = props.publishedAt || new Date(); 255 + return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; 256 + }); 257 + 258 + let currentTime = `${new Date().getHours().toString().padStart(2, "0")}:${new Date().getMinutes().toString().padStart(2, "0")}`; 259 + 260 + const handleTimeChange = (time: string) => { 261 + setTimeValue(time); 262 + if (!props.publishedAt) return; 263 + 264 + const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); 265 + const newDate = setHours(setMinutes(props.publishedAt, minutes), hours); 266 + const currentDate = new Date(); 267 + 268 + if (newDate > currentDate) { 269 + props.setPublishedAt(currentDate); 270 + setTimeValue(currentTime); 271 + } else props.setPublishedAt(newDate); 272 + }; 273 + 274 + const handleDateChange = (date: Date | undefined) => { 275 + if (!date) { 276 + props.setPublishedAt(undefined); 277 + return; 278 + } 279 + const [hours, minutes] = timeValue 280 + .split(":") 281 + .map((str) => parseInt(str, 10)); 282 + const newDate = new Date( 283 + date.getFullYear(), 284 + date.getMonth(), 285 + date.getDate(), 286 + hours, 287 + minutes, 288 + ); 289 + const currentDate = new Date(); 290 + if (newDate > currentDate) { 291 + props.setPublishedAt(currentDate); 292 + setTimeValue(currentTime); 293 + } else props.setPublishedAt(newDate); 294 + }; 295 + 296 + return ( 297 + <div className="flex justify-between gap-2"> 298 + <h4>Publish Date</h4> 299 + <Popover 300 + className="w-64 px-2!" 301 + trigger={ 302 + props.publishedAt ? ( 303 + <div className="text-secondary">{formattedDate}</div> 304 + ) : ( 305 + <div className="text-tertiary italic">now</div> 306 + ) 307 + } 308 + > 309 + <div className="flex flex-col gap-3"> 310 + <DatePicker 311 + selected={props.publishedAt} 312 + onSelect={handleDateChange} 313 + disabled={(date) => date > new Date()} 314 + /> 315 + <Separator className="border-border" /> 316 + <div className="flex gap-4 pb-1 items-center"> 317 + <TimePicker value={timeValue} onChange={handleTimeChange} /> 318 + </div> 319 + </div> 320 + </Popover> 321 + </div> 322 + ); 323 + }; 324 + 222 325 const ShareOptions = (props: { 223 326 shareOption: "quiet" | "bluesky"; 224 327 setShareOption: (option: typeof props.shareOption) => void; ··· 232 335 }) => { 233 336 return ( 234 337 <div className="flex flex-col gap-2"> 235 - <h4>Notifications</h4> 338 + <h4>Share and Notify</h4> 236 339 <Radio 237 340 checked={props.shareOption === "quiet"} 238 341 onChange={(e) => {
+2 -32
components/Blocks/DateTimeBlock.tsx
··· 1 1 import { useEntity, useReplicache } from "src/replicache"; 2 2 import { BlockProps, BlockLayout } from "./Block"; 3 - import { ChevronProps, DayPicker } from "react-day-picker"; 4 3 import { Popover } from "components/Popover"; 5 4 import { useEffect, useMemo, useState } from "react"; 6 5 import { useEntitySetContext } from "components/EntitySetProvider"; ··· 10 9 import { Checkbox } from "components/Checkbox"; 11 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 12 11 import { useSpring, animated } from "@react-spring/web"; 13 - import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 14 12 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; 13 + import { DatePicker } from "components/DatePicker"; 15 14 16 15 export function DateTimeBlock(props: BlockProps) { 17 16 const [isClient, setIsClient] = useState(false); ··· 167 166 } 168 167 > 169 168 <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" 169 + <DatePicker 192 170 selected={dateFact ? selectedDate : undefined} 193 171 onSelect={handleDaySelect} 194 172 /> ··· 230 208 let spring = useSpring({ opacity: props.active ? 1 : 0 }); 231 209 return <animated.div style={spring}>{props.children}</animated.div>; 232 210 }; 233 - 234 - const CustomChevron = (props: ChevronProps) => { 235 - return ( 236 - <div {...props} className="w-full pointer-events-none"> 237 - <ArrowRightTiny /> 238 - </div> 239 - ); 240 - };
+71
components/DatePicker.tsx
··· 1 + import { ChevronProps, DayPicker as ReactDayPicker } from "react-day-picker"; 2 + import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; 3 + 4 + const CustomChevron = (props: ChevronProps) => { 5 + return ( 6 + <div {...props} className="w-full pointer-events-none"> 7 + <ArrowRightTiny /> 8 + </div> 9 + ); 10 + }; 11 + 12 + interface DayPickerProps { 13 + selected: Date | undefined; 14 + onSelect: (date: Date | undefined) => void; 15 + disabled?: (date: Date) => boolean; 16 + } 17 + 18 + export const DatePicker = ({ 19 + selected, 20 + onSelect, 21 + disabled, 22 + }: DayPickerProps) => { 23 + return ( 24 + <ReactDayPicker 25 + components={{ 26 + Chevron: (props: ChevronProps) => <CustomChevron {...props} />, 27 + }} 28 + classNames={{ 29 + months: "relative", 30 + month_caption: 31 + "font-bold text-center w-full bg-border-light mb-2 py-1 rounded-md", 32 + button_next: 33 + "absolute right-0 top-1 p-1 text-secondary hover:text-accent-contrast flex align-center", 34 + button_previous: 35 + "absolute left-0 top-1 p-1 text-secondary hover:text-accent-contrast rotate-180 flex align-center", 36 + chevron: "text-inherit", 37 + month_grid: "w-full table-fixed", 38 + weekdays: "text-secondary text-sm", 39 + selected: "bg-accent-1! text-accent-2 rounded-md font-bold", 40 + day: "h-[34px] text-center rounded-md sm:hover:bg-border-light", 41 + outside: "text-tertiary", 42 + today: "font-bold", 43 + disabled: "text-border cursor-not-allowed hover:bg-transparent!", 44 + }} 45 + mode="single" 46 + selected={selected} 47 + defaultMonth={selected} 48 + onSelect={onSelect} 49 + disabled={disabled} 50 + /> 51 + ); 52 + }; 53 + 54 + export const TimePicker = (props: { 55 + value: string; 56 + onChange: (time: string) => void; 57 + className?: string; 58 + }) => { 59 + let handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { 60 + props.onChange(e.target.value); 61 + }; 62 + 63 + return ( 64 + <input 65 + type="time" 66 + value={props.value} 67 + onChange={handleTimeChange} 68 + className={`dateBlockTimeInput input-with-border bg-bg-page text-primary w-full ${props.className}`} 69 + /> 70 + ); 71 + };
+84
components/Pages/Backdater.tsx
··· 1 + "use client"; 2 + import { DatePicker, TimePicker } from "components/DatePicker"; 3 + import { useMemo, useState } from "react"; 4 + import { timeAgo } from "src/utils/timeAgo"; 5 + import { Popover } from "components/Popover"; 6 + import { Separator } from "react-aria-components"; 7 + import { useReplicache } from "src/replicache"; 8 + import { create } from "zustand"; 9 + 10 + export const useLocalPublishedAt = create<{ [uri: string]: Date }>(() => ({})); 11 + export const Backdater = (props: { publishedAt: string; docURI: string }) => { 12 + let { rep } = useReplicache(); 13 + let localPublishedAtDate = useLocalPublishedAt((s) => 14 + s[props.docURI] ? s[props.docURI] : null, 15 + ); 16 + let localPublishedAt = useMemo( 17 + () => localPublishedAtDate || new Date(props.publishedAt), 18 + [localPublishedAtDate, props.publishedAt], 19 + ); 20 + 21 + let [timeValue, setTimeValue] = useState( 22 + `${localPublishedAt.getHours().toString().padStart(2, "0")}:${localPublishedAt.getMinutes().toString().padStart(2, "0")}`, 23 + ); 24 + 25 + let currentTime = `${new Date().getHours().toString().padStart(2, "0")}:${new Date().getMinutes().toString().padStart(2, "0")}`; 26 + 27 + const handleTimeChange = async (time: string) => { 28 + setTimeValue(time); 29 + const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); 30 + const newDate = new Date(localPublishedAt); 31 + newDate.setHours(hours); 32 + newDate.setMinutes(minutes); 33 + 34 + let currentDate = new Date(); 35 + if (newDate > currentDate) { 36 + useLocalPublishedAt.setState({ [props.docURI]: currentDate }); 37 + setTimeValue(currentTime); 38 + } else { 39 + useLocalPublishedAt.setState({ [props.docURI]: newDate }); 40 + } 41 + }; 42 + 43 + const handleDateChange = async (date: Date | undefined) => { 44 + if (!date) return; 45 + const [hours, minutes] = timeValue 46 + .split(":") 47 + .map((str) => parseInt(str, 10)); 48 + const newDate = new Date(date); 49 + newDate.setHours(hours); 50 + newDate.setMinutes(minutes); 51 + 52 + let currentDate = new Date(); 53 + if (newDate > currentDate) { 54 + useLocalPublishedAt.setState({ [props.docURI]: currentDate }); 55 + 56 + setTimeValue(currentTime); 57 + } else { 58 + useLocalPublishedAt.setState({ [props.docURI]: newDate }); 59 + } 60 + }; 61 + 62 + return ( 63 + <Popover 64 + className="w-64 z-10 px-2!" 65 + trigger={ 66 + <div className="underline"> 67 + {timeAgo(localPublishedAt.toISOString())} 68 + </div> 69 + } 70 + > 71 + <div className="flex flex-col gap-3"> 72 + <DatePicker 73 + selected={localPublishedAt} 74 + onSelect={handleDateChange} 75 + disabled={(date) => date > new Date()} 76 + /> 77 + <Separator className="border-border" /> 78 + <div className="flex gap-4 pb-1 items-center"> 79 + <TimePicker value={timeValue} onChange={handleTimeChange} /> 80 + </div> 81 + </div> 82 + </Popover> 83 + ); 84 + };
+6 -1
components/Pages/PublicationMetadata.tsx
··· 20 20 import { TagSelector } from "components/Tags"; 21 21 import { useIdentityData } from "components/IdentityProvider"; 22 22 import { PostHeaderLayout } from "app/lish/[did]/[publication]/[rkey]/PostHeader/PostHeader"; 23 + import { Backdater } from "./Backdater"; 24 + 23 25 export const PublicationMetadata = () => { 24 26 let { rep } = useReplicache(); 25 27 let { data: pub } = useLeafletPublicationData(); ··· 96 98 {pub.doc ? ( 97 99 <div className="flex gap-2 items-center"> 98 100 <p className="text-sm text-tertiary"> 99 - Published {publishedAt && timeAgo(publishedAt)} 101 + Published{" "} 102 + {publishedAt && ( 103 + <Backdater publishedAt={publishedAt} docURI={pub.doc} /> 104 + )} 100 105 </p> 101 106 102 107 <Link
+4 -2
src/replicache/mutations.ts
··· 637 637 description?: string; 638 638 tags?: string[]; 639 639 cover_image?: string | null; 640 + localPublishedAt?: string | null; 640 641 }> = async (args, ctx) => { 641 642 await ctx.runOnServer(async (serverCtx) => { 642 643 console.log("updating"); ··· 670 671 } 671 672 }); 672 673 await ctx.runOnClient(async ({ tx }) => { 673 - if (args.title !== undefined) 674 - await tx.set("publication_title", args.title); 674 + if (args.title !== undefined) await tx.set("publication_title", args.title); 675 675 if (args.description !== undefined) 676 676 await tx.set("publication_description", args.description); 677 677 if (args.tags !== undefined) await tx.set("publication_tags", args.tags); 678 678 if (args.cover_image !== undefined) 679 679 await tx.set("publication_cover_image", args.cover_image); 680 + if (args.localPublishedAt !== undefined) 681 + await tx.set("publication_local_published_at", args.localPublishedAt); 680 682 }); 681 683 }; 682 684