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 tags, 67 cover_image, 68 entitiesToDelete, 69 }: { 70 root_entity: string; 71 publication_uri?: string; ··· 75 tags?: string[]; 76 cover_image?: string | null; 77 entitiesToDelete?: string[]; 78 }): Promise<PublishResult> { 79 let identity = await getIdentityData(); 80 if (!identity || !identity.atp_did) { ··· 147 credentialSession.did!, 148 ); 149 150 - let existingRecord = 151 - (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {}; 152 153 // Extract theme for standalone documents (not for publications) 154 let theme: PubLeafletPublication.Theme | undefined; ··· 174 } 175 176 let record: PubLeafletDocument.Record = { 177 - publishedAt: new Date().toISOString(), 178 - ...existingRecord, 179 $type: "pub.leaflet.document", 180 author: credentialSession.did!, 181 ...(publication_uri && { publication: publication_uri }), ··· 199 }; 200 } 201 }), 202 }; 203 204 // Keep the same rkey if updating an existing document
··· 66 tags, 67 cover_image, 68 entitiesToDelete, 69 + publishedAt, 70 }: { 71 root_entity: string; 72 publication_uri?: string; ··· 76 tags?: string[]; 77 cover_image?: string | null; 78 entitiesToDelete?: string[]; 79 + publishedAt?: string; 80 }): Promise<PublishResult> { 81 let identity = await getIdentityData(); 82 if (!identity || !identity.atp_did) { ··· 149 credentialSession.did!, 150 ); 151 152 + let existingRecord = draft?.documents?.data as 153 + | PubLeafletDocument.Record 154 + | undefined; 155 156 // Extract theme for standalone documents (not for publications) 157 let theme: PubLeafletPublication.Theme | undefined; ··· 177 } 178 179 let record: PubLeafletDocument.Record = { 180 $type: "pub.leaflet.document", 181 author: credentialSession.did!, 182 ...(publication_uri && { publication: publication_uri }), ··· 200 }; 201 } 202 }), 203 + publishedAt: 204 + existingRecord?.publishedAt || publishedAt || new Date().toISOString(), 205 }; 206 207 // Keep the same rkey if updating an existing document
+112 -8
app/[leaflet_id]/publish/PublishPost.tsx
··· 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 import { PubIcon } from "components/ActionBar/Publications"; 25 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 26 27 type Props = { 28 title: string; ··· 78 ); 79 let [localTags, setLocalTags] = useState<string[]>([]); 80 81 // Get cover image from Replicache 82 let replicacheCoverImage = useSubscribe(rep, (tx) => 83 tx.get<string | null>("publication_cover_image"), ··· 116 tags: currentTags, 117 cover_image: replicacheCoverImage, 118 entitiesToDelete: props.entitiesToDelete, 119 }); 120 121 if (!result.success) { ··· 168 record={props.record} 169 /> 170 <hr className="border-border" /> 171 - <ShareOptions 172 - setShareOption={setShareOption} 173 - shareOption={shareOption} 174 - charCount={charCount} 175 - setCharCount={setCharCount} 176 - editorStateRef={editorStateRef} 177 - {...props} 178 /> 179 <hr className="border-border " /> 180 <div className="flex flex-col gap-2"> 181 <h4>Tags</h4> 182 <TagSelector ··· 184 setSelectedTags={handleTagsChange} 185 /> 186 </div> 187 <hr className="border-border mb-2" /> 188 189 <div className="flex flex-col gap-2"> ··· 219 ); 220 }; 221 222 const ShareOptions = (props: { 223 shareOption: "quiet" | "bluesky"; 224 setShareOption: (option: typeof props.shareOption) => void; ··· 232 }) => { 233 return ( 234 <div className="flex flex-col gap-2"> 235 - <h4>Notifications</h4> 236 <Radio 237 checked={props.shareOption === "quiet"} 238 onChange={(e) => {
··· 23 import { LooseLeafSmall } from "components/Icons/LooseleafSmall"; 24 import { PubIcon } from "components/ActionBar/Publications"; 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"; 31 32 type Props = { 33 title: string; ··· 83 ); 84 let [localTags, setLocalTags] = useState<string[]>([]); 85 86 + let [localPublishedAt, setLocalPublishedAt] = useState<Date | undefined>( 87 + undefined, 88 + ); 89 // Get cover image from Replicache 90 let replicacheCoverImage = useSubscribe(rep, (tx) => 91 tx.get<string | null>("publication_cover_image"), ··· 124 tags: currentTags, 125 cover_image: replicacheCoverImage, 126 entitiesToDelete: props.entitiesToDelete, 127 + publishedAt: localPublishedAt?.toISOString() || new Date().toISOString(), 128 }); 129 130 if (!result.success) { ··· 177 record={props.record} 178 /> 179 <hr className="border-border" /> 180 + 181 + <BackdateOptions 182 + publishedAt={localPublishedAt} 183 + setPublishedAt={setLocalPublishedAt} 184 /> 185 <hr className="border-border " /> 186 + 187 <div className="flex flex-col gap-2"> 188 <h4>Tags</h4> 189 <TagSelector ··· 191 setSelectedTags={handleTagsChange} 192 /> 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 + /> 203 <hr className="border-border mb-2" /> 204 205 <div className="flex flex-col gap-2"> ··· 235 ); 236 }; 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 + 326 const ShareOptions = (props: { 327 shareOption: "quiet" | "bluesky"; 328 setShareOption: (option: typeof props.shareOption) => void; ··· 336 }) => { 337 return ( 338 <div className="flex flex-col gap-2"> 339 + <h4>Share and Notify</h4> 340 <Radio 341 checked={props.shareOption === "quiet"} 342 onChange={(e) => {
+2 -2
components/Blocks/DateTimeBlock.tsx
··· 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 import { useSpring, animated } from "@react-spring/web"; 12 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; 13 - import { DayPicker } from "components/DatePicker"; 14 15 export function DateTimeBlock(props: BlockProps) { 16 const [isClient, setIsClient] = useState(false); ··· 166 } 167 > 168 <div className="flex flex-col gap-3 "> 169 - <DayPicker 170 selected={dateFact ? selectedDate : undefined} 171 onSelect={handleDaySelect} 172 />
··· 10 import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 11 import { useSpring, animated } from "@react-spring/web"; 12 import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; 13 + import { DatePicker } from "components/DatePicker"; 14 15 export function DateTimeBlock(props: BlockProps) { 16 const [isClient, setIsClient] = useState(false); ··· 166 } 167 > 168 <div className="flex flex-col gap-3 "> 169 + <DatePicker 170 selected={dateFact ? selectedDate : undefined} 171 onSelect={handleDaySelect} 172 />
+20 -4
components/DatePicker.tsx
··· 13 selected: Date | undefined; 14 onSelect: (date: Date | undefined) => void; 15 disabled?: (date: Date) => boolean; 16 - toDate?: Date; 17 } 18 19 - export const DayPicker = ({ 20 selected, 21 onSelect, 22 disabled, 23 - toDate, 24 }: DayPickerProps) => { 25 return ( 26 <ReactDayPicker ··· 48 selected={selected} 49 onSelect={onSelect} 50 disabled={disabled} 51 - toDate={toDate} 52 /> 53 ); 54 };
··· 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 ··· 46 selected={selected} 47 onSelect={onSelect} 48 disabled={disabled} 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}`} 68 /> 69 ); 70 };
+50 -47
components/Pages/Backdater.tsx
··· 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"; 8 import { useState } from "react"; 9 import { timeAgo } from "src/utils/timeAgo"; 10 import { Popover } from "components/Popover"; 11 12 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(); 17 18 - const handleDaySelect = async (date: Date | undefined) => { 19 - if (!date || !pub?.doc || isUpdating) return; 20 21 - // Prevent future dates 22 - if (date > new Date()) return; 23 24 - setIsUpdating(true); 25 - try { 26 - const result = await backdatePost({ 27 - uri: pub.doc, 28 - publishedAt: date.toISOString(), 29 - }); 30 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 - } 46 }; 47 48 - const selectedDate = new Date(localPublishedAt); 49 50 return ( 51 <Popover 52 className="w-64 z-10 px-2!" 53 trigger={ 54 - isUpdating ? ( 55 - <DotLoader className="h-[21px]!" /> 56 - ) : ( 57 - <div className="underline">{timeAgo(localPublishedAt)}</div> 58 - ) 59 } 60 > 61 - <DayPicker 62 - selected={selectedDate} 63 - onSelect={handleDaySelect} 64 - disabled={(date) => date > new Date()} 65 - toDate={new Date()} 66 - /> 67 </Popover> 68 ); 69 };
··· 1 "use client"; 2 + import { DatePicker, TimePicker } from "components/DatePicker"; 3 import { useState } from "react"; 4 import { timeAgo } from "src/utils/timeAgo"; 5 import { Popover } from "components/Popover"; 6 + import { Separator } from "react-aria-components"; 7 8 export const Backdater = (props: { publishedAt: string }) => { 9 + let [localPublishedAt, setLocalPublishedAt] = useState( 10 + new Date(props.publishedAt), 11 + ); 12 13 + let [timeValue, setTimeValue] = useState( 14 + `${localPublishedAt.getHours().toString().padStart(2, "0")}:${localPublishedAt.getMinutes().toString().padStart(2, "0")}`, 15 + ); 16 17 + let currentTime = `${new Date().getHours().toString().padStart(2, "0")}:${new Date().getMinutes().toString().padStart(2, "0")}`; 18 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); 25 26 + let currentDate = new Date(); 27 + if (newDate > currentDate) { 28 + setLocalPublishedAt(currentDate); 29 + setTimeValue(currentTime); 30 + } else setLocalPublishedAt(newDate); 31 }; 32 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 50 return ( 51 <Popover 52 className="w-64 z-10 px-2!" 53 trigger={ 54 + <div className="underline"> 55 + {timeAgo(localPublishedAt.toISOString())} 56 + </div> 57 } 58 > 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> 70 </Popover> 71 ); 72 };