a tool for shared writing and social publishing

make save as draft and publish work to existing pub

+206 -59
+6
actions/createPublicationDraft.ts
··· 11 11 redirectUser: false, 12 12 firstBlockType: "text", 13 13 }); 14 + let { data: publication } = await supabaseServerClient 15 + .from("publications") 16 + .select("*") 17 + .eq("uri", publication_uri) 18 + .single(); 19 + if (publication?.identity_did !== identity.atp_did) return; 14 20 15 21 await supabaseServerClient 16 22 .from("leaflets_in_publications")
+33
actions/publications/moveLeafletToPublication.ts
··· 1 + "use server"; 2 + 3 + import { getIdentityData } from "actions/getIdentityData"; 4 + import { supabaseServerClient } from "supabase/serverClient"; 5 + 6 + export async function moveLeafletToPublication( 7 + leaflet_id: string, 8 + publication_uri: string, 9 + metadata: { title: string; description: string }, 10 + entitiesToDelete: string[], 11 + ) { 12 + let identity = await getIdentityData(); 13 + if (!identity || !identity.atp_did) return null; 14 + let { data: publication } = await supabaseServerClient 15 + .from("publications") 16 + .select("*") 17 + .eq("uri", publication_uri) 18 + .single(); 19 + if (publication?.identity_did !== identity.atp_did) return; 20 + 21 + await supabaseServerClient.from("leaflets_in_publications").insert({ 22 + publication: publication_uri, 23 + leaflet: leaflet_id, 24 + doc: null, 25 + title: metadata.title, 26 + description: metadata.description, 27 + }); 28 + 29 + await supabaseServerClient 30 + .from("entities") 31 + .delete() 32 + .in("id", entitiesToDelete); 33 + }
-26
actions/publications/updateLeafletDraftMetadata.ts
··· 1 - "use server"; 2 - 3 - import { getIdentityData } from "actions/getIdentityData"; 4 - import { supabaseServerClient } from "supabase/serverClient"; 5 - 6 - export async function updateLeafletDraftMetadata( 7 - leafletID: string, 8 - publication_uri: string, 9 - title: string, 10 - description: string, 11 - ) { 12 - let identity = await getIdentityData(); 13 - if (!identity?.atp_did) return null; 14 - let { data: publication } = await supabaseServerClient 15 - .from("publications") 16 - .select() 17 - .eq("uri", publication_uri) 18 - .single(); 19 - if (!publication || publication.identity_did !== identity.atp_did) 20 - return null; 21 - await supabaseServerClient 22 - .from("leaflets_in_publications") 23 - .update({ title, description }) 24 - .eq("leaflet", leafletID) 25 - .eq("publication", publication_uri); 26 - }
+1 -1
app/[leaflet_id]/Footer.tsx
··· 46 46 <HomeButton /> 47 47 )} 48 48 49 - <PublishButton /> 49 + <PublishButton entityID={props.entityID} /> 50 50 <ShareOptions /> 51 51 <ThemePopover entityID={props.entityID} /> 52 52 </ActionFooter>
+136 -25
app/[leaflet_id]/actions/PublishButton.tsx
··· 1 + "use client"; 1 2 import { publishToPublication } from "actions/publishToPublication"; 2 3 import { getPublicationURL } from "app/lish/createPub/getPublicationURL"; 3 4 import { ActionButton } from "components/ActionBar/ActionButton"; 4 5 import { 5 6 PubIcon, 6 7 PubListEmptyContent, 8 + PubListEmptyIllo, 7 9 } from "components/ActionBar/Publications"; 8 10 import { ButtonPrimary, ButtonTertiary } from "components/Buttons"; 9 11 import { AddSmall } from "components/Icons/AddSmall"; ··· 12 14 import { useIdentityData } from "components/IdentityProvider"; 13 15 import { InputWithLabel } from "components/Input"; 14 16 import { Menu, MenuItem } from "components/Layout"; 15 - import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 17 + import { 18 + useLeafletDomains, 19 + useLeafletPublicationData, 20 + } from "components/PageSWRDataProvider"; 16 21 import { Popover } from "components/Popover"; 17 22 import { SpeedyLink } from "components/SpeedyLink"; 18 23 import { useToaster } from "components/Toast"; 19 24 import { DotLoader } from "components/utils/DotLoader"; 20 25 import { PubLeafletPublication } from "lexicons/api"; 21 - import { useParams, useRouter } from "next/navigation"; 26 + import { useParams, useRouter, useSearchParams } from "next/navigation"; 22 27 import { useState, useMemo } from "react"; 23 28 import { useIsMobile } from "src/hooks/isMobile"; 24 29 import { useReplicache, useEntity } from "src/replicache"; ··· 27 32 import * as Y from "yjs"; 28 33 import * as base64 from "base64-js"; 29 34 import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 35 + import { BlueskyLogin } from "app/login/LoginForm"; 36 + import { moveLeafletToPublication } from "actions/publications/moveLeafletToPublication"; 30 37 31 38 export const PublishButton = (props: { entityID: string }) => { 32 39 let { data: pub } = useLeafletPublicationData(); ··· 92 99 93 100 const PublishToPublicationButton = (props: { entityID: string }) => { 94 101 let { identity } = useIdentityData(); 102 + let { permission_token } = useReplicache(); 103 + let query = useSearchParams(); 104 + console.log(query.get("publish")); 105 + let [open, setOpen] = useState(query.get("publish") !== null); 95 106 96 107 let isMobile = useIsMobile(); 97 108 identity && identity.atp_did && identity.publications.length > 0; 98 109 let [selectedPub, setSelectedPub] = useState<string | undefined>(undefined); 110 + let router = useRouter(); 111 + let { title, entitiesToDelete } = useTitle(props.entityID); 112 + let [description, setDescription] = useState(""); 99 113 100 114 return ( 101 115 <Popover 102 116 asChild 117 + open={open} 118 + onOpenChange={(o) => setOpen(o)} 103 119 side={isMobile ? "top" : "right"} 104 120 align={isMobile ? "center" : "start"} 105 121 className="sm:max-w-sm w-[1000px]" ··· 112 128 } 113 129 > 114 130 {!identity || !identity.atp_did ? ( 115 - // this component is also used on Home to populate the sidebar when PubList is empty 116 - // when user doesn't have an AT Proto account, and redirects back to the doc (hopefully with publish open? 117 131 <div className="-mx-2 -my-1"> 118 - <PubListEmptyContent compact /> 132 + <div 133 + className={`bg-[var(--accent-light)] w-full rounded-md flex flex-col text-center justify-center p-2 pb-4 text-sm`} 134 + > 135 + <div className="mx-auto pt-2 scale-90"> 136 + <PubListEmptyIllo /> 137 + </div> 138 + <div className="pt-1 font-bold">Publish on AT Proto</div> 139 + { 140 + <> 141 + <div className="pb-2 text-secondary text-xs"> 142 + Link a Bluesky account to start <br /> a publishing on AT 143 + Proto 144 + </div> 145 + 146 + <BlueskyLogin 147 + compact 148 + redirectRoute={`/${permission_token.id}?publish`} 149 + /> 150 + </> 151 + } 152 + </div> 119 153 </div> 120 154 ) : ( 121 155 <div className="flex flex-col"> 122 - <PostDetailsForm entityID={props.entityID} /> 156 + <PostDetailsForm 157 + title={title} 158 + description={description} 159 + setDescription={setDescription} 160 + /> 123 161 <hr className="border-border-light my-3" /> 124 162 <div> 125 163 <PubSelector ··· 131 169 <hr className="border-border-light mt-3 mb-2" /> 132 170 133 171 <div className="flex gap-2 items-center place-self-end"> 134 - <ButtonTertiary>Save as Draft</ButtonTertiary> 135 - <ButtonPrimary disabled={selectedPub === undefined}> 172 + <SaveAsDraftButton 173 + selectedPub={selectedPub} 174 + leafletId={permission_token.id} 175 + metadata={{ title: title, description }} 176 + entitiesToDelete={entitiesToDelete} 177 + /> 178 + <ButtonPrimary 179 + disabled={selectedPub === undefined} 180 + onClick={async (e) => { 181 + if (!selectedPub) return; 182 + e.preventDefault(); 183 + if (selectedPub === "create") return; 184 + router.push( 185 + `${permission_token.id}/publish?publication_uri=${encodeURIComponent(selectedPub)}&title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`, 186 + ); 187 + }} 188 + > 136 189 Next{selectedPub === "create" && ": Create Pub!"} 137 190 </ButtonPrimary> 138 191 </div> ··· 142 195 ); 143 196 }; 144 197 145 - const PostDetailsForm = (props: { entityID: string }) => { 146 - let [description, setDescription] = useState(""); 198 + const SaveAsDraftButton = (props: { 199 + selectedPub: string | undefined; 200 + leafletId: string; 201 + metadata: { title: string; description: string }; 202 + entitiesToDelete: string[]; 203 + }) => { 204 + let { mutate } = useLeafletPublicationData(); 205 + let { rep } = useReplicache(); 206 + let [isLoading, setIsLoading] = useState(false); 147 207 148 - let rootPage = useEntity(props.entityID, "root/page")[0].data.value; 149 - let firstBlock = useBlocks(rootPage)[0]; 150 - let firstBlockText = useEntity(firstBlock?.value, "block/text")?.data.value; 208 + return ( 209 + <ButtonTertiary 210 + onClick={async (e) => { 211 + if (!props.selectedPub) return; 212 + if (props.selectedPub === "create") return; 213 + e.preventDefault(); 214 + setIsLoading(true); 215 + await moveLeafletToPublication( 216 + props.leafletId, 217 + props.selectedPub, 218 + props.metadata, 219 + props.entitiesToDelete, 220 + ); 221 + await Promise.all([rep?.pull(), mutate()]); 222 + setIsLoading(false); 223 + }} 224 + > 225 + {isLoading ? <DotLoader /> : "Save as Draft"} 226 + </ButtonTertiary> 227 + ); 228 + }; 151 229 152 - const leafletTitle = useMemo(() => { 153 - if (!firstBlockText) return "Untitled"; 154 - let doc = new Y.Doc(); 155 - const update = base64.toByteArray(firstBlockText); 156 - Y.applyUpdate(doc, update); 157 - let nodes = doc.getXmlElement("prosemirror").toArray(); 158 - return YJSFragmentToString(nodes[0]) || "Untitled"; 159 - }, [firstBlockText]); 160 - 230 + const PostDetailsForm = (props: { 231 + title: string; 232 + description: string; 233 + setDescription: (d: string) => void; 234 + }) => { 161 235 return ( 162 236 <div className=" flex flex-col gap-1"> 163 237 <div className="text-sm text-tertiary">Post Details</div> 164 238 <div className="flex flex-col gap-2"> 165 - <InputWithLabel label="Title" value={leafletTitle} disabled /> 239 + <InputWithLabel label="Title" value={props.title} disabled /> 166 240 <InputWithLabel 167 241 label="Description (optional)" 168 242 textarea 169 - value={description} 243 + value={props.description} 170 244 className="h-[4lh]" 171 - onChange={(e) => setDescription(e.currentTarget.value)} 245 + onChange={(e) => props.setDescription(e.currentTarget.value)} 172 246 /> 173 247 </div> 174 248 </div> ··· 271 345 </button> 272 346 ); 273 347 }; 348 + 349 + let useTitle = (entityID: string) => { 350 + let rootPage = useEntity(entityID, "root/page")[0].data.value; 351 + let firstBlock = useBlocks(rootPage)[0]?.value; 352 + 353 + let firstBlockText = useEntity(firstBlock, "block/text")?.data.value; 354 + 355 + const leafletTitle = useMemo(() => { 356 + if (!firstBlockText) return "Untitled"; 357 + let doc = new Y.Doc(); 358 + const update = base64.toByteArray(firstBlockText); 359 + Y.applyUpdate(doc, update); 360 + let nodes = doc.getXmlElement("prosemirror").toArray(); 361 + return YJSFragmentToString(nodes[0]) || "Untitled"; 362 + }, [firstBlockText]); 363 + 364 + let secondBlock = useBlocks(rootPage)[1]; 365 + let secondBlockTextValue = useEntity(secondBlock.value, "block/text")?.data 366 + .value; 367 + const secondBlockText = useMemo(() => { 368 + if (!secondBlockTextValue) return ""; 369 + let doc = new Y.Doc(); 370 + const update = base64.toByteArray(secondBlockTextValue); 371 + Y.applyUpdate(doc, update); 372 + let nodes = doc.getXmlElement("prosemirror").toArray(); 373 + return YJSFragmentToString(nodes[0]) || ""; 374 + }, [firstBlockText]); 375 + 376 + let entitiesToDelete = useMemo(() => { 377 + let etod = [firstBlock]; 378 + if (secondBlockText.trim() === "" && secondBlock.type === "text") 379 + etod.push(secondBlock.value); 380 + return etod; 381 + }, [firstBlock, secondBlockText, secondBlock]); 382 + 383 + return { title: leafletTitle, entitiesToDelete }; 384 + };
+30 -7
app/[leaflet_id]/publish/page.tsx
··· 13 13 type Props = { 14 14 // this is now a token id not leaflet! Should probs rename 15 15 params: Promise<{ leaflet_id: string }>; 16 + searchParams: Promise<{ 17 + publication_uri: string; 18 + title: string; 19 + description: string; 20 + }>; 16 21 }; 17 22 export default async function PublishLeafletPage(props: Props) { 18 23 let leaflet_id = (await props.params).leaflet_id; ··· 32 37 .eq("id", leaflet_id) 33 38 .single(); 34 39 let rootEntity = data?.root_entity; 35 - if (!data || !rootEntity || !data.leaflets_in_publications[0]) 40 + let publication = data?.leaflets_in_publications[0]?.publications; 41 + if (!publication) { 42 + let pub_uri = (await props.searchParams).publication_uri; 43 + if (!pub_uri) return; 44 + console.log(decodeURIComponent(pub_uri)); 45 + let { data, error } = await supabaseServerClient 46 + .from("publications") 47 + .select("*, documents_in_publications(count)") 48 + .eq("uri", decodeURIComponent(pub_uri)) 49 + .single(); 50 + console.log(error); 51 + publication = data; 52 + } 53 + if (!data || !rootEntity || !publication) 36 54 return ( 37 55 <div> 38 56 missin something ··· 42 60 43 61 let identity = await getIdentityData(); 44 62 if (!identity || !identity.atp_did) return null; 45 - let pub = data.leaflets_in_publications[0]; 63 + let title = 64 + data.leaflets_in_publications[0]?.title || 65 + decodeURIComponent((await props.searchParams).title); 66 + let description = 67 + data.leaflets_in_publications[0]?.description || 68 + decodeURIComponent((await props.searchParams).description); 46 69 let agent = new AtpAgent({ service: "https://public.api.bsky.app" }); 47 70 48 71 let profile = await agent.getProfile({ actor: identity.atp_did }); ··· 57 80 leaflet_id={leaflet_id} 58 81 root_entity={rootEntity} 59 82 profile={profile.data} 60 - title={pub.title} 61 - publication_uri={pub.publication} 62 - description={pub.description} 63 - record={pub.publications?.record as PubLeafletPublication.Record} 64 - posts_in_pub={pub.publications?.documents_in_publications[0].count} 83 + title={title} 84 + description={description} 85 + publication_uri={publication.uri} 86 + record={publication.record as PubLeafletPublication.Record} 87 + posts_in_pub={publication.documents_in_publications[0].count} 65 88 /> 66 89 </ReplicacheProvider> 67 90 );