a tool for shared writing and social publishing

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

+191 -65
+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 + existingRecord?.publishedAt || publishedAt || new Date().toISOString(), 202 205 }; 203 206 204 207 // Keep the same rkey if updating an existing document
+112 -8
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"), ··· 116 124 tags: currentTags, 117 125 cover_image: replicacheCoverImage, 118 126 entitiesToDelete: props.entitiesToDelete, 127 + publishedAt: localPublishedAt?.toISOString() || new Date().toISOString(), 119 128 }); 120 129 121 130 if (!result.success) { ··· 168 177 record={props.record} 169 178 /> 170 179 <hr className="border-border" /> 171 - <ShareOptions 172 - setShareOption={setShareOption} 173 - shareOption={shareOption} 174 - charCount={charCount} 175 - setCharCount={setCharCount} 176 - editorStateRef={editorStateRef} 177 - {...props} 180 + 181 + <BackdateOptions 182 + publishedAt={localPublishedAt} 183 + setPublishedAt={setLocalPublishedAt} 178 184 /> 179 185 <hr className="border-border " /> 186 + 180 187 <div className="flex flex-col gap-2"> 181 188 <h4>Tags</h4> 182 189 <TagSelector ··· 184 191 setSelectedTags={handleTagsChange} 185 192 /> 186 193 </div> 194 + <hr className="border-border" /> 195 + <ShareOptions 196 + setShareOption={setShareOption} 197 + shareOption={shareOption} 198 + charCount={charCount} 199 + setCharCount={setCharCount} 200 + editorStateRef={editorStateRef} 201 + {...props} 202 + /> 187 203 <hr className="border-border mb-2" /> 188 204 189 205 <div className="flex flex-col gap-2"> ··· 219 235 ); 220 236 }; 221 237 238 + const BackdateOptions = (props: { 239 + publishedAt: Date | undefined; 240 + setPublishedAt: (date: Date | undefined) => void; 241 + }) => { 242 + const formattedDate = useLocalizedDate( 243 + props.publishedAt?.toISOString() || "", 244 + { 245 + month: "short", 246 + day: "numeric", 247 + year: "numeric", 248 + hour: "numeric", 249 + minute: "numeric", 250 + hour12: true, 251 + }, 252 + ); 253 + 254 + const [timeValue, setTimeValue] = useState<string>(() => { 255 + if (!props.publishedAt) return "12:00"; 256 + return `${props.publishedAt.getHours().toString().padStart(2, "0")}:${props.publishedAt.getMinutes().toString().padStart(2, "0")}`; 257 + }); 258 + 259 + let currentTime = `${new Date().getHours().toString().padStart(2, "0")}:${new Date().getMinutes().toString().padStart(2, "0")}`; 260 + 261 + const handleTimeChange = (time: string) => { 262 + setTimeValue(time); 263 + if (!props.publishedAt) return; 264 + 265 + const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); 266 + const newDate = setHours(setMinutes(props.publishedAt, minutes), hours); 267 + const currentDate = new Date(); 268 + 269 + if (newDate > currentDate) { 270 + props.setPublishedAt(currentDate); 271 + setTimeValue(currentTime); 272 + } else props.setPublishedAt(newDate); 273 + }; 274 + 275 + const handleDateChange = (date: Date | undefined) => { 276 + if (!date) { 277 + props.setPublishedAt(undefined); 278 + return; 279 + } 280 + const [hours, minutes] = timeValue 281 + .split(":") 282 + .map((str) => parseInt(str, 10)); 283 + const newDate = new Date( 284 + date.getFullYear(), 285 + date.getMonth(), 286 + date.getDate(), 287 + hours, 288 + minutes, 289 + ); 290 + const currentDate = new Date(); 291 + if (newDate > currentDate) { 292 + props.setPublishedAt(currentDate); 293 + setTimeValue(currentTime); 294 + } else props.setPublishedAt(newDate); 295 + }; 296 + 297 + return ( 298 + <div className="flex justify-between gap-2"> 299 + <h4>Publish Date</h4> 300 + <Popover 301 + className="w-64 px-2!" 302 + trigger={ 303 + props.publishedAt ? ( 304 + <div className="text-secondary">{formattedDate}</div> 305 + ) : ( 306 + <div className="text-tertiary italic">now</div> 307 + ) 308 + } 309 + > 310 + <div className="flex flex-col gap-3"> 311 + <DatePicker 312 + selected={props.publishedAt} 313 + onSelect={handleDateChange} 314 + disabled={(date) => date > new Date()} 315 + /> 316 + <Separator className="border-border" /> 317 + <div className="flex gap-4 pb-1 items-center"> 318 + <TimePicker value={timeValue} onChange={handleTimeChange} /> 319 + </div> 320 + </div> 321 + </Popover> 322 + </div> 323 + ); 324 + }; 325 + 222 326 const ShareOptions = (props: { 223 327 shareOption: "quiet" | "bluesky"; 224 328 setShareOption: (option: typeof props.shareOption) => void; ··· 232 336 }) => { 233 337 return ( 234 338 <div className="flex flex-col gap-2"> 235 - <h4>Notifications</h4> 339 + <h4>Share and Notify</h4> 236 340 <Radio 237 341 checked={props.shareOption === "quiet"} 238 342 onChange={(e) => {
+2 -2
components/Blocks/DateTimeBlock.tsx
··· 10 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 11 import { useSpring, animated } from "@react-spring/web"; 12 12 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; 13 - import { DayPicker } from "components/DatePicker"; 13 + import { DatePicker } from "components/DatePicker"; 14 14 15 15 export function DateTimeBlock(props: BlockProps) { 16 16 const [isClient, setIsClient] = useState(false); ··· 166 166 } 167 167 > 168 168 <div className="flex flex-col gap-3 "> 169 - <DayPicker 169 + <DatePicker 170 170 selected={dateFact ? selectedDate : undefined} 171 171 onSelect={handleDaySelect} 172 172 />
+20 -4
components/DatePicker.tsx
··· 13 13 selected: Date | undefined; 14 14 onSelect: (date: Date | undefined) => void; 15 15 disabled?: (date: Date) => boolean; 16 - toDate?: Date; 17 16 } 18 17 19 - export const DayPicker = ({ 18 + export const DatePicker = ({ 20 19 selected, 21 20 onSelect, 22 21 disabled, 23 - toDate, 24 22 }: DayPickerProps) => { 25 23 return ( 26 24 <ReactDayPicker ··· 48 46 selected={selected} 49 47 onSelect={onSelect} 50 48 disabled={disabled} 51 - toDate={toDate} 49 + /> 50 + ); 51 + }; 52 + 53 + export const TimePicker = (props: { 54 + value: string; 55 + onChange: (time: string) => void; 56 + className?: string; 57 + }) => { 58 + let handleTimeChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { 59 + props.onChange(e.target.value); 60 + }; 61 + 62 + return ( 63 + <input 64 + type="time" 65 + value={props.value} 66 + onChange={handleTimeChange} 67 + className={`dateBlockTimeInput input-with-border bg-bg-page text-primary w-full ${props.className}`} 52 68 /> 53 69 ); 54 70 };
+50 -47
components/Pages/Backdater.tsx
··· 1 1 "use client"; 2 - import { DayPicker } from "components/DatePicker"; 3 - import { backdatePost } from "actions/backdatePost"; 4 - import { mutate } from "swr"; 5 - import { DotLoader } from "components/utils/DotLoader"; 6 - import { useToaster } from "components/Toast"; 7 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 2 + import { DatePicker, TimePicker } from "components/DatePicker"; 8 3 import { useState } from "react"; 9 4 import { timeAgo } from "src/utils/timeAgo"; 10 5 import { Popover } from "components/Popover"; 6 + import { Separator } from "react-aria-components"; 11 7 12 8 export const Backdater = (props: { publishedAt: string }) => { 13 - let { data: pub } = useLeafletPublicationData(); 14 - let [isUpdating, setIsUpdating] = useState(false); 15 - let [localPublishedAt, setLocalPublishedAt] = useState(props.publishedAt); 16 - let toaster = useToaster(); 9 + let [localPublishedAt, setLocalPublishedAt] = useState( 10 + new Date(props.publishedAt), 11 + ); 17 12 18 - const handleDaySelect = async (date: Date | undefined) => { 19 - if (!date || !pub?.doc || isUpdating) return; 13 + let [timeValue, setTimeValue] = useState( 14 + `${localPublishedAt.getHours().toString().padStart(2, "0")}:${localPublishedAt.getMinutes().toString().padStart(2, "0")}`, 15 + ); 20 16 21 - // Prevent future dates 22 - if (date > new Date()) return; 17 + let currentTime = `${new Date().getHours().toString().padStart(2, "0")}:${new Date().getMinutes().toString().padStart(2, "0")}`; 23 18 24 - setIsUpdating(true); 25 - try { 26 - const result = await backdatePost({ 27 - uri: pub.doc, 28 - publishedAt: date.toISOString(), 29 - }); 19 + const handleTimeChange = (time: string) => { 20 + setTimeValue(time); 21 + const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); 22 + const newDate = new Date(localPublishedAt); 23 + newDate.setHours(hours); 24 + newDate.setMinutes(minutes); 30 25 31 - if (result.success) { 32 - // Update local state immediately 33 - setLocalPublishedAt(date.toISOString()); 34 - // Refresh the publication data 35 - await mutate(`/api/pub/${pub.doc}`); 36 - } 37 - } catch (error) { 38 - console.error("Failed to backdate document:", error); 39 - } finally { 40 - toaster({ 41 - content: <div className="font-bold">Updated publish date!</div>, 42 - type: "success", 43 - }); 44 - setIsUpdating(false); 45 - } 26 + let currentDate = new Date(); 27 + if (newDate > currentDate) { 28 + setLocalPublishedAt(currentDate); 29 + setTimeValue(currentTime); 30 + } else setLocalPublishedAt(newDate); 46 31 }; 47 32 48 - const selectedDate = new Date(localPublishedAt); 33 + const handleDateChange = (date: Date | undefined) => { 34 + if (!date) return; 35 + const [hours, minutes] = timeValue 36 + .split(":") 37 + .map((str) => parseInt(str, 10)); 38 + const newDate = new Date(date); 39 + newDate.setHours(hours); 40 + newDate.setMinutes(minutes); 41 + 42 + let currentDate = new Date(); 43 + if (newDate > currentDate) { 44 + setLocalPublishedAt(currentDate); 45 + setTimeValue(currentTime); 46 + } else setLocalPublishedAt(newDate); 47 + }; 48 + console.log(localPublishedAt); 49 49 50 50 return ( 51 51 <Popover 52 52 className="w-64 z-10 px-2!" 53 53 trigger={ 54 - isUpdating ? ( 55 - <DotLoader className="h-[21px]!" /> 56 - ) : ( 57 - <div className="underline">{timeAgo(localPublishedAt)}</div> 58 - ) 54 + <div className="underline"> 55 + {timeAgo(localPublishedAt.toISOString())} 56 + </div> 59 57 } 60 58 > 61 - <DayPicker 62 - selected={selectedDate} 63 - onSelect={handleDaySelect} 64 - disabled={(date) => date > new Date()} 65 - toDate={new Date()} 66 - /> 59 + <div className="flex flex-col gap-3"> 60 + <DatePicker 61 + selected={localPublishedAt} 62 + onSelect={handleDateChange} 63 + disabled={(date) => date > new Date()} 64 + /> 65 + <Separator className="border-border" /> 66 + <div className="flex gap-4 pb-1 items-center"> 67 + <TimePicker value={timeValue} onChange={handleTimeChange} /> 68 + </div> 69 + </div> 67 70 </Popover> 68 71 ); 69 72 };