a tool for shared writing and social publishing

added capability to backdate post on published docs

+214 -32
+86
actions/backdatePost.ts
··· 1 + "use server"; 2 + 3 + import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; 4 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 5 + import { getIdentityData } from "actions/getIdentityData"; 6 + import { supabaseServerClient } from "supabase/serverClient"; 7 + import { Json } from "supabase/database.types"; 8 + import { AtUri } from "@atproto/syntax"; 9 + 10 + type BackdateResult = 11 + | { success: true; publishedAt: string } 12 + | { success: false; error?: OAuthSessionError | string }; 13 + 14 + export async function backdatePost({ 15 + uri, 16 + publishedAt, 17 + }: { 18 + uri: string; 19 + publishedAt: string; 20 + }): Promise<BackdateResult> { 21 + let identity = await getIdentityData(); 22 + if (!identity || !identity.atp_did) { 23 + return { 24 + success: false, 25 + error: "Not authenticated", 26 + }; 27 + } 28 + 29 + const sessionResult = await restoreOAuthSession(identity.atp_did); 30 + if (!sessionResult.ok) { 31 + return { success: false, error: sessionResult.error }; 32 + } 33 + let credentialSession = sessionResult.value; 34 + let agent = new AtpBaseClient( 35 + credentialSession.fetchHandler.bind(credentialSession), 36 + ); 37 + 38 + // Get the existing document 39 + let { data: existingDoc } = await supabaseServerClient 40 + .from("documents") 41 + .select("*") 42 + .eq("uri", uri) 43 + .single(); 44 + 45 + if (!existingDoc) { 46 + return { success: false, error: "Document not found" }; 47 + } 48 + 49 + let record = existingDoc.data as PubLeafletDocument.Record; 50 + 51 + // Check if the user is the author 52 + if (record.author !== identity.atp_did) { 53 + return { success: false, error: "Not authorized" }; 54 + } 55 + 56 + let aturi = new AtUri(uri); 57 + 58 + // Update the record with the new publishedAt date 59 + let updatedRecord: PubLeafletDocument.Record = { 60 + ...record, 61 + publishedAt, 62 + }; 63 + 64 + // Update the record on ATP 65 + let result = await agent.com.atproto.repo.putRecord({ 66 + repo: credentialSession.did!, 67 + rkey: aturi.rkey, 68 + record: updatedRecord, 69 + collection: record.$type, 70 + validate: false, 71 + }); 72 + 73 + // Optimistically write to our db 74 + let { error } = await supabaseServerClient 75 + .from("documents") 76 + .update({ 77 + data: updatedRecord as Json, 78 + }) 79 + .eq("uri", uri); 80 + 81 + if (error) { 82 + return { success: false, error: error.message }; 83 + } 84 + 85 + return { success: true, publishedAt }; 86 + }
+1 -31
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 { DayPicker } from "components/DatePicker"; 15 14 16 15 export function DateTimeBlock(props: BlockProps) { 17 16 const [isClient, setIsClient] = useState(false); ··· 168 167 > 169 168 <div className="flex flex-col gap-3 "> 170 169 <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" 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 - };
+54
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 + toDate?: Date; 17 + } 18 + 19 + export const DayPicker = ({ 20 + selected, 21 + onSelect, 22 + disabled, 23 + toDate, 24 + }: DayPickerProps) => { 25 + return ( 26 + <ReactDayPicker 27 + components={{ 28 + Chevron: (props: ChevronProps) => <CustomChevron {...props} />, 29 + }} 30 + classNames={{ 31 + months: "relative", 32 + month_caption: 33 + "font-bold text-center w-full bg-border-light mb-2 py-1 rounded-md", 34 + button_next: 35 + "absolute right-0 top-1 p-1 text-secondary hover:text-accent-contrast flex align-center", 36 + button_previous: 37 + "absolute left-0 top-1 p-1 text-secondary hover:text-accent-contrast rotate-180 flex align-center", 38 + chevron: "text-inherit", 39 + month_grid: "w-full table-fixed", 40 + weekdays: "text-secondary text-sm", 41 + selected: "bg-accent-1! text-accent-2 rounded-md font-bold", 42 + day: "h-[34px] text-center rounded-md sm:hover:bg-border-light", 43 + outside: "text-tertiary", 44 + today: "font-bold", 45 + disabled: "text-border cursor-not-allowed hover:bg-transparent!", 46 + }} 47 + mode="single" 48 + selected={selected} 49 + onSelect={onSelect} 50 + disabled={disabled} 51 + toDate={toDate} 52 + /> 53 + ); 54 + };
+69
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 + };
+4 -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 && <Backdater publishedAt={publishedAt} />} 100 103 </p> 101 104 102 105 <Link